2 * Copyright (c) 2010-2022 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.net.URLDecoder;
18 import java.nio.charset.StandardCharsets;
19 import java.time.Instant;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.HashSet;
25 import java.util.List;
28 import java.util.concurrent.CompletableFuture;
29 import java.util.concurrent.ConcurrentHashMap;
30 import java.util.concurrent.ExecutionException;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34 import java.util.regex.Matcher;
35 import java.util.regex.Pattern;
36 import java.util.stream.Collectors;
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.jupnp.model.meta.RemoteDevice;
41 import org.openhab.binding.upnpcontrol.internal.UpnpChannelName;
42 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicCommandDescriptionProvider;
43 import org.openhab.binding.upnpcontrol.internal.UpnpDynamicStateDescriptionProvider;
44 import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink;
45 import org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSinkReg;
46 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
47 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
48 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
49 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
50 import org.openhab.binding.upnpcontrol.internal.queue.UpnpFavorite;
51 import org.openhab.binding.upnpcontrol.internal.services.UpnpRenderingControlConfiguration;
52 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
53 import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
54 import org.openhab.core.audio.AudioFormat;
55 import org.openhab.core.io.net.http.HttpUtil;
56 import org.openhab.core.io.transport.upnp.UpnpIOService;
57 import org.openhab.core.library.types.DecimalType;
58 import org.openhab.core.library.types.NextPreviousType;
59 import org.openhab.core.library.types.OnOffType;
60 import org.openhab.core.library.types.PercentType;
61 import org.openhab.core.library.types.PlayPauseType;
62 import org.openhab.core.library.types.QuantityType;
63 import org.openhab.core.library.types.RewindFastforwardType;
64 import org.openhab.core.library.types.StringType;
65 import org.openhab.core.library.unit.Units;
66 import org.openhab.core.thing.Channel;
67 import org.openhab.core.thing.ChannelUID;
68 import org.openhab.core.thing.Thing;
69 import org.openhab.core.thing.ThingStatus;
70 import org.openhab.core.thing.ThingStatusDetail;
71 import org.openhab.core.types.Command;
72 import org.openhab.core.types.CommandOption;
73 import org.openhab.core.types.RefreshType;
74 import org.openhab.core.types.State;
75 import org.openhab.core.types.UnDefType;
76 import org.slf4j.Logger;
77 import org.slf4j.LoggerFactory;
80 * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
81 * {@link UpnpHandler} with UPnP renderer specific logic. It implements UPnP AVTransport and RenderingControl service
84 * @author Mark Herwege - Initial contribution
85 * @author Karel Goderis - Based on UPnP logic in Sonos binding
88 public class UpnpRendererHandler extends UpnpHandler {
90 private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
93 static final String RENDERING_CONTROL = "RenderingControl";
94 static final String AV_TRANSPORT = "AVTransport";
95 static final String INSTANCE_ID = "InstanceID";
97 private volatile boolean audioSupport;
98 protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
99 private volatile boolean audioSinkRegistered;
101 private volatile UpnpAudioSinkReg audioSinkReg;
103 private volatile Set<UpnpServerHandler> serverHandlers = ConcurrentHashMap.newKeySet();
105 protected @NonNullByDefault({}) UpnpControlRendererConfiguration config;
106 private UpnpRenderingControlConfiguration renderingControlConfiguration = new UpnpRenderingControlConfiguration();
108 private volatile List<CommandOption> favoriteCommandOptionList = List.of();
109 private volatile List<CommandOption> playlistCommandOptionList = List.of();
111 private @NonNullByDefault({}) ChannelUID favoriteSelectChannelUID;
112 private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
114 private volatile PercentType soundVolume = new PercentType();
115 private @Nullable volatile PercentType notificationVolume;
116 private volatile List<String> sink = new ArrayList<>();
118 private volatile String favoriteName = ""; // Currently selected favorite
120 private volatile boolean repeat;
121 private volatile boolean shuffle;
122 private volatile boolean onlyplayone; // Set to true if we only want to play one at a time
124 // Queue as received from server and current and next media entries for playback
125 private volatile UpnpEntryQueue currentQueue = new UpnpEntryQueue();
126 volatile @Nullable UpnpEntry currentEntry = null;
127 volatile @Nullable UpnpEntry nextEntry = null;
129 // Group of fields representing current state of player
130 private volatile String nowPlayingUri = ""; // Used to block waiting for setting URI when it is the same as current
131 // as some players will not send URI update when it is the same as
133 private volatile String transportState = ""; // Current transportState to be able to refresh the control
134 volatile boolean playerStopped; // Set if the player is stopped from OH command or code, allows to identify
135 // if STOP came from other source when receiving STOP state from GENA event
136 volatile boolean playing; // Set to false when a STOP is received, so we can filter two consecutive STOPs
137 // and not play next entry second time
138 private volatile @Nullable ScheduledFuture<?> paused; // Set when a pause command is given, to compensate for
139 // renderers that cannot pause playback
140 private volatile @Nullable CompletableFuture<Boolean> isSettingURI; // Set to wait for setting URI before starting
141 // to play or seeking
142 private volatile @Nullable CompletableFuture<Boolean> isStopping; // Set when stopping to be able to wait for stop
143 // confirmation for subsequent actions that need
144 // the player to be stopped
145 volatile boolean registeredQueue; // Set when registering a new queue. This allows to decide if we just
146 // need to play URI, or serve the first entry in a queue when a play
148 volatile boolean playingQueue; // Identifies if we are playing a queue received from a server. If so, a new
149 // queue received will be played after the currently playing entry
150 private volatile boolean oneplayed; // Set to true when the one entry is being played, allows to check if stop is
151 // needed when only playing one
152 volatile boolean playingNotification; // Set when playing a notification
153 private volatile @Nullable ScheduledFuture<?> playingNotificationFuture; // Set when playing a notification, allows
154 // timing out notification
155 private volatile String notificationUri = ""; // Used to check if the received URI is from the notification
156 private final Object notificationLock = new Object();
158 // Track position and duration fields
159 private volatile int trackDuration = 0;
160 private volatile int trackPosition = 0;
161 private volatile long expectedTrackend = 0;
162 private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
163 private volatile int posAtNotificationStart = 0;
165 public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg,
166 UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
167 UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
168 UpnpControlBindingConfiguration configuration) {
169 super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
171 serviceSubscriptions.add(AV_TRANSPORT);
172 serviceSubscriptions.add(RENDERING_CONTROL);
174 this.audioSinkReg = audioSinkReg;
178 public void initialize() {
180 config = getConfigAs(UpnpControlRendererConfiguration.class);
181 if (config.seekStep < 1) {
184 logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
186 Channel favoriteSelectChannel = thing.getChannel(FAVORITE_SELECT);
187 if (favoriteSelectChannel != null) {
188 favoriteSelectChannelUID = favoriteSelectChannel.getUID();
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
191 "Channel " + FAVORITE_SELECT + " not defined");
194 Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
195 if (playlistSelectChannel != null) {
196 playlistSelectChannelUID = playlistSelectChannel.getUID();
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
199 "Channel " + PLAYLIST_SELECT + " not defined");
207 public void dispose() {
208 logger.debug("Disposing handler for media renderer device {}", thing.getLabel());
210 cancelTrackPositionRefresh();
212 CompletableFuture<Boolean> settingURI = isSettingURI;
213 if (settingURI != null) {
214 settingURI.complete(false);
221 protected void initJob() {
222 synchronized (jobLock) {
223 if (!upnpIOService.isRegistered(this)) {
224 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
225 "UPnP device with UDN " + getUDN() + " not yet registered");
229 if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
232 getCurrentConnectionInfo();
233 if (!checkForConnectionIds()) {
234 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
235 "No connection Id's set for UPnP device with UDN " + getUDN());
241 updateFavoritesList();
242 playlistsListChanged();
244 RemoteDevice device = getDevice();
245 if (device != null) { // The handler factory will update the device config later when it has not been
247 updateDeviceConfig(device);
250 updateStatus(ThingStatus.ONLINE);
253 if (!upnpSubscribed) {
260 public void updateDeviceConfig(RemoteDevice device) {
261 super.updateDeviceConfig(device);
263 UpnpRenderingControlConfiguration config = new UpnpRenderingControlConfiguration(device);
264 renderingControlConfiguration = config;
265 for (String audioChannel : config.audioChannels) {
266 createAudioChannels(audioChannel);
272 private void createAudioChannels(String audioChannel) {
273 UpnpRenderingControlConfiguration config = renderingControlConfiguration;
274 if (config.volume && !UPNP_MASTER.equals(audioChannel)) {
275 String name = audioChannel + "volume";
276 if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
277 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
279 createChannel(name, name, "Vendor specific UPnP volume channel", ITEM_TYPE_VOLUME, CHANNEL_TYPE_VOLUME);
282 if (config.mute && !UPNP_MASTER.equals(audioChannel)) {
283 String name = audioChannel + "mute";
284 if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
285 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
287 createChannel(name, name, "Vendor specific UPnP mute channel", ITEM_TYPE_MUTE, CHANNEL_TYPE_MUTE);
290 if (config.loudness) {
291 String name = (UPNP_MASTER.equals(audioChannel) ? "" : audioChannel) + "loudness";
292 if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
293 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
295 createChannel(name, name, "Vendor specific UPnP loudness channel", ITEM_TYPE_LOUDNESS,
296 CHANNEL_TYPE_LOUDNESS);
302 * Invoke Stop on UPnP AV Transport.
305 playerStopped = true;
308 CompletableFuture<Boolean> stopping = isStopping;
309 if (stopping != null) {
310 stopping.complete(false);
312 isStopping = new CompletableFuture<Boolean>(); // set this so we can check if stop confirmation has been
316 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
318 invokeAction(AV_TRANSPORT, "Stop", inputs);
322 * Invoke Play on UPnP AV Transport.
325 CompletableFuture<Boolean> settingURI = isSettingURI;
326 boolean uriSet = true;
328 if (settingURI != null) {
329 // wait for maximum 2.5s until the media URI is set before playing
330 uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
332 } catch (InterruptedException | ExecutionException | TimeoutException e) {
333 logger.debug("Timeout exception, media URI not yet set in renderer {}, trying to play anyway",
338 Map<String, String> inputs = new HashMap<>();
339 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
340 inputs.put("Speed", "1");
342 invokeAction(AV_TRANSPORT, "Play", inputs);
344 logger.debug("Cannot play, cancelled setting URI in the renderer {}", thing.getLabel());
349 * Invoke Pause on UPnP AV Transport.
351 protected void pause() {
352 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
354 invokeAction(AV_TRANSPORT, "Pause", inputs);
358 * Invoke Next on UPnP AV Transport.
360 protected void next() {
361 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
363 invokeAction(AV_TRANSPORT, "Next", inputs);
367 * Invoke Previous on UPnP AV Transport.
369 protected void previous() {
370 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
372 invokeAction(AV_TRANSPORT, "Previous", inputs);
376 * Invoke Seek on UPnP AV Transport.
378 * @param seekTarget relative position in current track, format HH:mm:ss
380 protected void seek(String seekTarget) {
381 CompletableFuture<Boolean> settingURI = isSettingURI;
382 boolean uriSet = true;
384 if (settingURI != null) {
385 // wait for maximum 2.5s until the media URI is set before seeking
386 uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
388 } catch (InterruptedException | ExecutionException | TimeoutException e) {
389 logger.debug("Timeout exception, media URI not yet set in renderer {}, skipping seek", thing.getLabel());
394 Map<String, String> inputs = new HashMap<>();
395 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
396 inputs.put("Unit", "REL_TIME");
397 inputs.put("Target", seekTarget);
399 invokeAction(AV_TRANSPORT, "Seek", inputs);
401 logger.debug("Cannot seek, cancelled setting URI in the renderer {}", thing.getLabel());
406 * Invoke SetAVTransportURI on UPnP AV Transport.
411 public void setCurrentURI(String URI, String URIMetaData) {
413 uri = URLDecoder.decode(URI.trim(), StandardCharsets.UTF_8);
414 // Some renderers don't send a URI Last Changed event when the same URI is requested, so don't wait for it
415 // before starting to play
416 if (!uri.equals(nowPlayingUri) && !playingNotification) {
417 CompletableFuture<Boolean> settingURI = isSettingURI;
418 if (settingURI != null) {
419 settingURI.complete(false);
421 isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished
424 logger.debug("New URI {} is same as previous on renderer {}", nowPlayingUri, thing.getLabel());
427 Map<String, String> inputs = new HashMap<>();
428 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
429 inputs.put("CurrentURI", uri);
430 inputs.put("CurrentURIMetaData", URIMetaData);
432 invokeAction(AV_TRANSPORT, "SetAVTransportURI", inputs);
436 * Invoke SetNextAVTransportURI on UPnP AV Transport.
439 * @param nextURIMetaData
441 protected void setNextURI(String nextURI, String nextURIMetaData) {
442 Map<String, String> inputs = new HashMap<>();
443 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
444 inputs.put("NextURI", nextURI);
445 inputs.put("NextURIMetaData", nextURIMetaData);
447 invokeAction(AV_TRANSPORT, "SetNextAVTransportURI", inputs);
451 * Invoke GetTransportState on UPnP AV Transport.
452 * Result is received in {@link onValueReceived}.
454 protected void getTransportState() {
455 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
457 invokeAction(AV_TRANSPORT, "GetTransportInfo", inputs);
461 * Invoke getPositionInfo on UPnP AV Transport.
462 * Result is received in {@link onValueReceived}.
464 protected void getPositionInfo() {
465 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
467 invokeAction(AV_TRANSPORT, "GetPositionInfo", inputs);
471 * Invoke GetMediaInfo on UPnP AV Transport.
472 * Result is received in {@link onValueReceived}.
474 protected void getMediaInfo() {
475 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
477 invokeAction(AV_TRANSPORT, "smarthome:audio stream http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3", inputs);
481 * Retrieves the current volume known to the control point, gets updated by GENA events or after UPnP Rendering
482 * Control GetVolume call. This method is used to retrieve volume by {@link UpnpAudioSink.getVolume}.
484 * @return current volume
486 public PercentType getCurrentVolume() {
491 * Invoke GetVolume on UPnP Rendering Control.
492 * Result is received in {@link onValueReceived}.
496 protected void getVolume(String channel) {
497 Map<String, String> inputs = new HashMap<>();
498 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
499 inputs.put("Channel", channel);
501 invokeAction(RENDERING_CONTROL, "GetVolume", inputs);
505 * Invoke SetVolume on UPnP Rendering Control.
510 protected void setVolume(String channel, PercentType volume) {
511 UpnpRenderingControlConfiguration config = renderingControlConfiguration;
513 long newVolume = volume.intValue() * config.maxvolume / 100;
514 Map<String, String> inputs = new HashMap<>();
515 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
516 inputs.put("Channel", channel);
517 inputs.put("DesiredVolume", String.valueOf(newVolume));
519 invokeAction(RENDERING_CONTROL, "SetVolume", inputs);
523 * Invoke SetVolume for Master channel on UPnP Rendering Control.
527 public void setVolume(PercentType volume) {
528 setVolume(UPNP_MASTER, volume);
532 * Invoke getMute on UPnP Rendering Control.
533 * Result is received in {@link onValueReceived}.
537 protected void getMute(String channel) {
538 Map<String, String> inputs = new HashMap<>();
539 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
540 inputs.put("Channel", channel);
542 invokeAction(RENDERING_CONTROL, "GetMute", inputs);
546 * Invoke SetMute on UPnP Rendering Control.
551 protected void setMute(String channel, OnOffType mute) {
552 Map<String, String> inputs = new HashMap<>();
553 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
554 inputs.put("Channel", channel);
555 inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
557 invokeAction(RENDERING_CONTROL, "SetMute", inputs);
561 * Invoke getMute on UPnP Rendering Control.
562 * Result is received in {@link onValueReceived}.
566 protected void getLoudness(String channel) {
567 Map<String, String> inputs = new HashMap<>();
568 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
569 inputs.put("Channel", channel);
571 invokeAction(RENDERING_CONTROL, "GetLoudness", inputs);
575 * Invoke SetMute on UPnP Rendering Control.
580 protected void setLoudness(String channel, OnOffType mute) {
581 Map<String, String> inputs = new HashMap<>();
582 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
583 inputs.put("Channel", channel);
584 inputs.put("DesiredLoudness", mute == OnOffType.ON ? "1" : "0");
586 invokeAction(RENDERING_CONTROL, "SetLoudness", inputs);
590 * Called from server handler for renderer to be able to send back status to server handler
594 protected void setServerHandler(UpnpServerHandler handler) {
595 logger.debug("Set server handler {} on renderer {}", handler.getThing().getLabel(), thing.getLabel());
596 serverHandlers.add(handler);
600 * Should be called from server handler when server stops serving this renderer
602 protected void unsetServerHandler() {
603 logger.debug("Unset server handler on renderer {}", thing.getLabel());
604 for (UpnpServerHandler handler : serverHandlers) {
605 Thing serverThing = handler.getThing();
606 Channel serverChannel;
607 for (String channel : SERVER_CONTROL_CHANNELS) {
608 if ((serverChannel = serverThing.getChannel(channel)) != null) {
609 handler.updateServerState(serverChannel.getUID(), UnDefType.UNDEF);
613 serverHandlers.remove(handler);
618 protected void updateState(ChannelUID channelUID, State state) {
619 // override to be able to propagate channel state updates to corresponding channels on the server
620 if (SERVER_CONTROL_CHANNELS.contains(channelUID.getId())) {
621 for (UpnpServerHandler handler : serverHandlers) {
622 Thing serverThing = handler.getThing();
623 Channel serverChannel = serverThing.getChannel(channelUID.getId());
624 if (serverChannel != null) {
625 logger.debug("Update server {} channel {} with state {} from renderer {}", serverThing.getLabel(),
626 state, channelUID, thing.getLabel());
627 handler.updateServerState(serverChannel.getUID(), state);
631 super.updateState(channelUID, state);
635 public void handleCommand(ChannelUID channelUID, Command command) {
636 logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
638 String id = channelUID.getId();
640 if (id.endsWith("volume")) {
641 handleCommandVolume(command, id);
642 } else if (id.endsWith("mute")) {
643 handleCommandMute(command, id);
644 } else if (id.endsWith("loudness")) {
645 handleCommandLoudness(command, id);
649 handleCommandStop(command);
652 handleCommandControl(channelUID, command);
655 handleCommandRepeat(channelUID, command);
658 handleCommandShuffle(channelUID, command);
660 handleCommandOnlyPlayOne(channelUID, command);
663 handleCommandUri(channelUID, command);
665 case FAVORITE_SELECT:
666 handleCommandFavoriteSelect(command);
669 handleCommandFavorite(channelUID, command);
671 case FAVORITE_ACTION:
672 handleCommandFavoriteAction(command);
674 case PLAYLIST_SELECT:
675 handleCommandPlaylistSelect(command);
678 handleCommandTrackPosition(channelUID, command);
680 case REL_TRACK_POSITION:
681 handleCommandRelTrackPosition(channelUID, command);
689 private void handleCommandVolume(Command command, String id) {
690 if (command instanceof RefreshType) {
691 getVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""));
692 } else if (command instanceof PercentType) {
693 setVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""), (PercentType) command);
697 private void handleCommandMute(Command command, String id) {
698 if (command instanceof RefreshType) {
699 getMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""));
700 } else if (command instanceof OnOffType) {
701 setMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""), (OnOffType) command);
705 private void handleCommandLoudness(Command command, String id) {
706 if (command instanceof RefreshType) {
707 getLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""));
708 } else if (command instanceof OnOffType) {
709 setLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""), (OnOffType) command);
713 private void handleCommandStop(Command command) {
714 if (OnOffType.ON.equals(command)) {
715 updateState(CONTROL, PlayPauseType.PAUSE);
717 updateState(TRACK_POSITION, new QuantityType<>(0, Units.SECOND));
721 private void handleCommandControl(ChannelUID channelUID, Command command) {
723 if (command instanceof RefreshType) {
724 state = transportState;
725 State newState = UnDefType.UNDEF;
726 if ("PLAYING".equals(state)) {
727 newState = PlayPauseType.PLAY;
728 } else if ("STOPPED".equals(state)) {
729 newState = PlayPauseType.PAUSE;
730 } else if ("PAUSED_PLAYBACK".equals(state)) {
731 newState = PlayPauseType.PAUSE;
733 updateState(channelUID, newState);
734 } else if (command instanceof PlayPauseType) {
735 if (PlayPauseType.PLAY.equals(command)) {
736 if (registeredQueue) {
737 registeredQueue = false;
744 } else if (PlayPauseType.PAUSE.equals(command)) {
748 } else if (command instanceof NextPreviousType) {
749 if (NextPreviousType.NEXT.equals(command)) {
751 } else if (NextPreviousType.PREVIOUS.equals(command)) {
754 } else if (command instanceof RewindFastforwardType) {
756 if (RewindFastforwardType.FASTFORWARD.equals(command)) {
757 pos = Integer.min(trackDuration, trackPosition + config.seekStep);
758 } else if (command == RewindFastforwardType.REWIND) {
759 pos = Integer.max(0, trackPosition - config.seekStep);
761 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
765 private void handleCommandRepeat(ChannelUID channelUID, Command command) {
766 if (command instanceof RefreshType) {
767 updateState(channelUID, OnOffType.from(repeat));
769 repeat = (OnOffType.ON.equals(command));
770 currentQueue.setRepeat(repeat);
771 updateState(channelUID, OnOffType.from(repeat));
772 logger.debug("Repeat set to {} for {}", repeat, thing.getLabel());
776 private void handleCommandShuffle(ChannelUID channelUID, Command command) {
777 if (command instanceof RefreshType) {
778 updateState(channelUID, OnOffType.from(shuffle));
780 shuffle = (OnOffType.ON.equals(command));
781 currentQueue.setShuffle(shuffle);
785 updateState(channelUID, OnOffType.from(shuffle));
786 logger.debug("Shuffle set to {} for {}", shuffle, thing.getLabel());
790 private void handleCommandOnlyPlayOne(ChannelUID channelUID, Command command) {
791 if (command instanceof RefreshType) {
792 updateState(channelUID, OnOffType.from(onlyplayone));
794 onlyplayone = (OnOffType.ON.equals(command));
795 oneplayed = (onlyplayone && playing) ? true : false;
799 UpnpEntry next = nextEntry;
801 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
804 updateState(channelUID, OnOffType.from(onlyplayone));
805 logger.debug("OnlyPlayOne set to {} for {}", onlyplayone, thing.getLabel());
809 private void handleCommandUri(ChannelUID channelUID, Command command) {
810 if (command instanceof RefreshType) {
811 updateState(channelUID, StringType.valueOf(nowPlayingUri));
812 } else if (command instanceof StringType) {
813 setCurrentURI(command.toString(), "");
818 private void handleCommandFavoriteSelect(Command command) {
819 if (command instanceof StringType) {
820 favoriteName = command.toString();
821 updateState(FAVORITE, StringType.valueOf(favoriteName));
826 private void handleCommandFavorite(ChannelUID channelUID, Command command) {
827 if (command instanceof StringType) {
828 favoriteName = command.toString();
829 if (favoriteCommandOptionList.contains(new CommandOption(favoriteName, favoriteName))) {
833 updateState(channelUID, StringType.valueOf(favoriteName));
836 private void handleCommandFavoriteAction(Command command) {
837 if (command instanceof StringType) {
838 switch (command.toString()) {
840 handleCommandFavoriteSave();
843 handleCommandFavoriteDelete();
849 private void handleCommandFavoriteSave() {
850 if (!favoriteName.isEmpty()) {
851 UpnpFavorite favorite = new UpnpFavorite(favoriteName, nowPlayingUri, currentEntry);
852 favorite.saveFavorite(favoriteName, bindingConfig.path);
853 updateFavoritesList();
857 private void handleCommandFavoriteDelete() {
858 if (!favoriteName.isEmpty()) {
859 UpnpControlUtil.deleteFavorite(favoriteName, bindingConfig.path);
860 updateFavoritesList();
861 updateState(FAVORITE, UnDefType.UNDEF);
865 private void handleCommandPlaylistSelect(Command command) {
866 if (command instanceof StringType) {
867 String playlistName = command.toString();
868 UpnpEntryQueue queue = new UpnpEntryQueue();
869 queue.restoreQueue(playlistName, null, bindingConfig.path);
870 registerQueue(queue);
877 private void handleCommandTrackPosition(ChannelUID channelUID, Command command) {
878 if (command instanceof RefreshType) {
879 updateState(channelUID, new QuantityType<>(trackPosition, Units.SECOND));
880 } else if (command instanceof QuantityType<?>) {
881 QuantityType<?> position = ((QuantityType<?>) command).toUnit(Units.SECOND);
882 if (position != null) {
883 int pos = Integer.min(trackDuration, position.intValue());
884 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
889 private void handleCommandRelTrackPosition(ChannelUID channelUID, Command command) {
890 if (command instanceof RefreshType) {
891 int relPosition = (trackDuration != 0) ? (trackPosition * 100) / trackDuration : 0;
892 updateState(channelUID, new PercentType(relPosition));
893 } else if (command instanceof PercentType) {
894 int pos = ((PercentType) command).intValue() * trackDuration / 100;
895 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
900 * Set the volume for notifications.
904 public void setNotificationVolume(PercentType volume) {
905 notificationVolume = volume;
909 * Play a notification. Previous state of the renderer will resume at the end of the notification, or after the
910 * maximum notification duration as defined in the renderer parameters.
912 * @param URI for notification sound
914 public void playNotification(String URI) {
915 synchronized (notificationLock) {
917 logger.debug("UPnP device {} received empty notification URI", thing.getLabel());
921 notificationUri = URI;
923 logger.debug("UPnP device {} playing notification {}", thing.getLabel(), URI);
925 cancelTrackPositionRefresh();
928 cancelPlayingNotificationFuture();
930 if (config.maxNotificationDuration > 0) {
931 playingNotificationFuture = upnpScheduler.schedule(this::stop, config.maxNotificationDuration,
934 playingNotification = true;
936 setCurrentURI(URI, "");
938 PercentType volume = notificationVolume;
939 setVolume(volume == null
940 ? new PercentType(Math.min(100,
941 Math.max(0, (100 + config.notificationVolumeAdjustment) * soundVolume.intValue() / 100)))
944 CompletableFuture<Boolean> stopping = isStopping;
946 if (stopping != null) {
947 // wait for maximum 2.5s until the renderer stopped before playing
948 stopping.get(config.responseTimeout, TimeUnit.MILLISECONDS);
950 } catch (InterruptedException | ExecutionException | TimeoutException e) {
951 logger.debug("Timeout exception, renderer {} didn't stop yet, trying to play anyway", thing.getLabel());
957 private void cancelPlayingNotificationFuture() {
958 ScheduledFuture<?> future = playingNotificationFuture;
959 if (future != null) {
961 playingNotificationFuture = null;
965 private void resumeAfterNotification() {
966 synchronized (notificationLock) {
967 logger.debug("UPnP device {} resume after playing notification", thing.getLabel());
969 setCurrentURI(nowPlayingUri, "");
970 setVolume(soundVolume);
972 cancelPlayingNotificationFuture();
974 playingNotification = false;
975 notificationVolume = null;
976 notificationUri = "";
979 int pos = posAtNotificationStart;
980 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
983 posAtNotificationStart = 0;
987 private void playFavorite() {
988 UpnpFavorite favorite = new UpnpFavorite(favoriteName, bindingConfig.path);
989 String uri = favorite.getUri();
990 UpnpEntry entry = favorite.getUpnpEntry();
991 if (!uri.isEmpty()) {
992 String metadata = "";
994 metadata = UpnpXMLParser.compileMetadataString(entry);
996 setCurrentURI(uri, metadata);
1001 void updateFavoritesList() {
1002 favoriteCommandOptionList = UpnpControlUtil.favorites(bindingConfig.path).stream()
1003 .map(p -> (new CommandOption(p, p))).collect(Collectors.toList());
1004 updateCommandDescription(favoriteSelectChannelUID, favoriteCommandOptionList);
1008 public void playlistsListChanged() {
1009 playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
1010 .collect(Collectors.toList());
1011 updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
1015 public void onStatusChanged(boolean status) {
1017 removeSubscriptions();
1019 updateState(CONTROL, PlayPauseType.PAUSE);
1020 cancelTrackPositionRefresh();
1022 super.onStatusChanged(status);
1026 protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
1027 @Nullable String value, @Nullable String service, @Nullable String action) {
1028 if (variable == null) {
1032 case "CurrentVolume":
1033 return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Volume";
1035 return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Mute";
1036 case "CurrentLoudness":
1037 return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Loudness";
1045 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
1046 if (logger.isTraceEnabled()) {
1047 logger.trace("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
1048 variable, value, service);
1050 if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
1051 || "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
1052 || "TrackDuration".equals(variable))) {
1053 // don't log all variables received when updating the track position every second
1054 logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
1055 variable, value, service);
1058 if (variable == null) {
1062 if (variable.endsWith("Volume")) {
1063 onValueReceivedVolume(variable, value);
1064 } else if (variable.endsWith("Mute")) {
1065 onValueReceivedMute(variable, value);
1066 } else if (variable.endsWith("Loudness")) {
1067 onValueReceivedLoudness(variable, value);
1071 onValueReceivedLastChange(value, service);
1073 case "CurrentTransportState":
1074 case "TransportState":
1075 onValueReceivedTransportState(value);
1077 case "CurrentTrackURI":
1079 onValueReceivedCurrentURI(value);
1081 case "CurrentTrackMetaData":
1082 case "CurrentURIMetaData":
1083 onValueReceivedCurrentMetaData(value);
1085 case "NextAVTransportURIMetaData":
1086 case "NextURIMetaData":
1087 onValueReceivedNextMetaData(value);
1089 case "CurrentTrackDuration":
1090 case "TrackDuration":
1091 onValueReceivedDuration(value);
1094 onValueReceivedRelTime(value);
1097 super.onValueReceived(variable, value, service);
1103 private void onValueReceivedVolume(String variable, @Nullable String value) {
1104 if (value != null && !value.isEmpty()) {
1105 UpnpRenderingControlConfiguration config = renderingControlConfiguration;
1107 long volume = Long.valueOf(value);
1108 volume = volume * 100 / config.maxvolume;
1110 String upnpChannel = variable.replace("Volume", "volume").replace("Master", "");
1111 updateState(upnpChannel, new PercentType((int) volume));
1113 if (!playingNotification && "volume".equals(upnpChannel)) {
1114 soundVolume = new PercentType((int) volume);
1119 private void onValueReceivedMute(String variable, @Nullable String value) {
1120 if (value != null && !value.isEmpty()) {
1121 String upnpChannel = variable.replace("Mute", "mute").replace("Master", "");
1122 updateState(upnpChannel,
1123 ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
1127 private void onValueReceivedLoudness(String variable, @Nullable String value) {
1128 if (value != null && !value.isEmpty()) {
1129 String upnpChannel = variable.replace("Loudness", "loudness").replace("Master", "");
1130 updateState(upnpChannel,
1131 ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
1135 private void onValueReceivedLastChange(@Nullable String value, @Nullable String service) {
1136 // This is returned from a GENA subscription. The jupnp library does not allow receiving new GENA subscription
1137 // messages as long as this thread has not finished. As we may trigger long running processes based on this
1138 // result, we run it in a separate thread.
1139 upnpScheduler.submit(() -> {
1140 // pre-process some variables, eg XML processing
1141 if (value != null && !value.isEmpty()) {
1142 if (AV_TRANSPORT.equals(service)) {
1143 Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
1144 for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
1145 switch (entrySet.getKey()) {
1146 case "TransportState":
1147 // Update the transport state after the update of the media information
1148 // to not break the notification mechanism
1150 case "AVTransportURI":
1151 onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
1153 case "AVTransportURIMetaData":
1154 onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
1157 onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
1160 if (parsedValues.containsKey("TransportState")) {
1161 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
1163 } else if (RENDERING_CONTROL.equals(service)) {
1164 Map<String, @Nullable String> parsedValues = UpnpXMLParser.getRenderingControlFromXML(value);
1165 for (String parsedValue : parsedValues.keySet()) {
1166 onValueReceived(parsedValue, parsedValues.get(parsedValue), RENDERING_CONTROL);
1173 private void onValueReceivedTransportState(@Nullable String value) {
1174 transportState = (value == null) ? "" : value;
1176 if ("STOPPED".equals(value)) {
1177 CompletableFuture<Boolean> stopping = isStopping;
1178 if (stopping != null) {
1179 stopping.complete(true); // We have received stop confirmation
1183 if (playingNotification) {
1184 resumeAfterNotification();
1188 cancelCheckPaused();
1189 updateState(CONTROL, PlayPauseType.PAUSE);
1190 cancelTrackPositionRefresh();
1191 // Only go to next for first STOP command, then wait until we received PLAYING before moving
1192 // to next (avoids issues with renderers sending multiple stop states)
1196 // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
1197 // end of an entry, because STOP would come from the player and not from openHAB. We should then
1198 // move to the next entry if the queue is not at the end already.
1199 if (!playerStopped) {
1200 if (Instant.now().toEpochMilli() >= expectedTrackend) {
1201 // If we are receiving track duration info, we know when the track is expected to end. If we
1202 // received STOP before track end, and it is not coming from openHAB, it must have been stopped
1203 // from the renderer directly, and we do not want to play the next entry.
1208 } else if (playingQueue) {
1209 playingQueue = false;
1212 } else if ("PLAYING".equals(value)) {
1213 if (playingNotification) {
1217 playerStopped = false;
1219 registeredQueue = false; // reset queue registration flag as we are playing something
1220 updateState(CONTROL, PlayPauseType.PLAY);
1221 scheduleTrackPositionRefresh();
1222 } else if ("PAUSED_PLAYBACK".equals(value)) {
1223 cancelCheckPaused();
1224 updateState(CONTROL, PlayPauseType.PAUSE);
1225 } else if ("NO_MEDIA_PRESENT".equals(value)) {
1226 updateState(CONTROL, UnDefType.UNDEF);
1230 private void onValueReceivedCurrentURI(@Nullable String value) {
1231 CompletableFuture<Boolean> settingURI = isSettingURI;
1232 if (settingURI != null) {
1233 settingURI.complete(true); // We have received current URI, so can allow play to start
1236 UpnpEntry current = currentEntry;
1237 UpnpEntry next = nextEntry;
1240 String currentUri = "";
1241 String nextUri = "";
1242 if (value != null) {
1243 uri = URLDecoder.decode(value.trim(), StandardCharsets.UTF_8);
1245 if (current != null) {
1246 currentUri = URLDecoder.decode(current.getRes().trim(), StandardCharsets.UTF_8);
1249 nextUri = URLDecoder.decode(next.getRes(), StandardCharsets.UTF_8);
1252 if (playingNotification && uri.equals(notificationUri)) {
1253 // No need to update anything more if this is for playing a notification
1257 nowPlayingUri = uri;
1258 updateState(URI, StringType.valueOf(uri));
1260 logger.trace("Renderer {} received URI: {}", thing.getLabel(), uri);
1261 logger.trace("Renderer {} current URI: {}, equal to received URI {}", thing.getLabel(), currentUri,
1262 uri.equals(currentUri));
1263 logger.trace("Renderer {} next URI: {}", thing.getLabel(), nextUri);
1265 if (!uri.equals(currentUri)) {
1266 if ((next != null) && uri.equals(nextUri)) {
1267 // Renderer advanced to next entry independent of openHAB UPnP control point.
1268 // Advance in the queue to keep proper position status.
1269 // Make the next entry available to renderers that support it.
1270 logger.trace("Renderer {} moved from '{}' to next entry '{}' in queue", thing.getLabel(), current,
1272 currentEntry = currentQueue.next();
1273 nextEntry = currentQueue.get(currentQueue.nextIndex());
1274 logger.trace("Renderer {} auto move forward, current queue index: {}", thing.getLabel(),
1275 currentQueue.index());
1277 updateMetaDataState(next);
1279 // look one further to get next entry for next URI
1281 if ((next != null) && !onlyplayone) {
1282 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1285 // A new entry is being served that does not match the next entry in the queue. This can be because a
1286 // sound or stream is being played through an action, or another control point started a new entry.
1287 // We should clear the metadata in this case and wait for new metadata to arrive.
1288 clearMetaDataState();
1293 private void onValueReceivedCurrentMetaData(@Nullable String value) {
1294 if (playingNotification) {
1295 // Don't update metadata when playing notification
1299 if (value != null && !value.isEmpty()) {
1300 List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
1301 if (!list.isEmpty()) {
1302 updateMetaDataState(list.get(0));
1306 clearMetaDataState();
1309 private void onValueReceivedNextMetaData(@Nullable String value) {
1310 if (value != null && !value.isEmpty() && !"NOT_IMPLEMENTED".equals(value)) {
1311 List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
1312 if (!list.isEmpty()) {
1313 nextEntry = list.get(0);
1318 private void onValueReceivedDuration(@Nullable String value) {
1319 // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
1320 // interested in the fractional seconds, so drop everything after . and calculate in seconds.
1321 if (value == null || "NOT_IMPLEMENTED".equals(value)) {
1323 updateState(TRACK_DURATION, UnDefType.UNDEF);
1324 updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1327 trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
1328 .reduce(0, (n, m) -> n * 60 + m);
1329 updateState(TRACK_DURATION, new QuantityType<>(trackDuration, Units.SECOND));
1330 } catch (NumberFormatException e) {
1331 logger.debug("Illegal format for track duration {}", value);
1335 setExpectedTrackend();
1338 private void onValueReceivedRelTime(@Nullable String value) {
1339 if (value == null || "NOT_IMPLEMENTED".equals(value)) {
1341 updateState(TRACK_POSITION, UnDefType.UNDEF);
1342 updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1345 trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
1346 .reduce(0, (n, m) -> n * 60 + m);
1347 updateState(TRACK_POSITION, new QuantityType<>(trackPosition, Units.SECOND));
1348 int relPosition = (trackDuration != 0) ? trackPosition * 100 / trackDuration : 0;
1349 updateState(REL_TRACK_POSITION, new PercentType(relPosition));
1350 } catch (NumberFormatException e) {
1351 logger.trace("Illegal format for track position {}", value);
1356 if (playingNotification) {
1357 posAtNotificationStart = trackPosition;
1360 setExpectedTrackend();
1364 protected void updateProtocolInfo(String value) {
1366 supportedAudioFormats.clear();
1367 audioSupport = false;
1369 sink.addAll(Arrays.asList(value.split(",")));
1371 for (String protocol : sink) {
1372 Matcher matcher = PROTOCOL_PATTERN.matcher(protocol);
1373 if (matcher.find()) {
1374 String format = matcher.group(1);
1379 supportedAudioFormats.add(AudioFormat.MP3);
1383 supportedAudioFormats.add(AudioFormat.WAV);
1386 audioSupport = audioSupport || Pattern.matches("audio.*", format);
1391 logger.debug("Renderer {} supports audio", thing.getLabel());
1392 registerAudioSink();
1396 private void clearCurrentEntry() {
1397 clearMetaDataState();
1400 updateState(TRACK_DURATION, UnDefType.UNDEF);
1402 updateState(TRACK_POSITION, UnDefType.UNDEF);
1403 updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1405 currentEntry = null;
1409 * Register a new queue with media entries to the renderer. Set the next position at the first entry in the list.
1410 * If the renderer is currently playing, set the first entry in the list as the next media. If not playing, set it
1415 protected void registerQueue(UpnpEntryQueue queue) {
1416 if (currentQueue.equals(queue)) {
1417 // We get the same queue, so do nothing
1421 logger.debug("Registering queue on renderer {}", thing.getLabel());
1423 registeredQueue = true;
1424 currentQueue = queue;
1425 currentQueue.setRepeat(repeat);
1426 currentQueue.setShuffle(shuffle);
1428 nextEntry = currentQueue.get(currentQueue.nextIndex());
1429 UpnpEntry next = nextEntry;
1430 if ((next != null) && !onlyplayone) {
1431 // make the next entry available to renderers that support it
1432 logger.trace("Renderer {} still playing, set new queue as next entry", thing.getLabel());
1433 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1436 resetToStartQueue();
1441 * Move to next position in queue and start playing.
1443 private void serveNext() {
1444 if (currentQueue.hasNext()) {
1445 currentEntry = currentQueue.next();
1446 nextEntry = currentQueue.get(currentQueue.nextIndex());
1447 logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
1448 logger.trace("Serve next, current queue index: {}", currentQueue.index());
1452 logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
1453 resetToStartQueue();
1458 * Move to previous position in queue and start playing.
1460 private void servePrevious() {
1461 if (currentQueue.hasPrevious()) {
1462 currentEntry = currentQueue.previous();
1463 nextEntry = currentQueue.get(currentQueue.nextIndex());
1464 logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
1465 logger.trace("Serve previous, current queue index: {}", currentQueue.index());
1469 logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
1470 resetToStartQueue();
1474 private void resetToStartQueue() {
1475 logger.trace("Reset to start queue on renderer {}", thing.getLabel());
1477 playingQueue = false;
1478 registeredQueue = true;
1482 currentQueue.resetIndex(); // reset to beginning of queue
1483 currentEntry = currentQueue.next();
1484 nextEntry = currentQueue.get(currentQueue.nextIndex());
1485 logger.trace("Reset queue, current queue index: {}", currentQueue.index());
1486 UpnpEntry current = currentEntry;
1487 if (current != null) {
1488 clearMetaDataState();
1489 updateMetaDataState(current);
1490 setCurrentURI(current.getRes(), UpnpXMLParser.compileMetadataString(current));
1492 clearCurrentEntry();
1495 UpnpEntry next = nextEntry;
1498 } else if (next != null) {
1499 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1504 * Serve media from a queue and play immediately when already playing.
1508 private void serve() {
1509 logger.trace("Serve media on renderer {}", thing.getLabel());
1511 UpnpEntry entry = currentEntry;
1512 if (entry != null) {
1513 clearMetaDataState();
1514 String res = entry.getRes();
1515 if (res.isEmpty()) {
1516 logger.debug("Renderer {} cannot serve media '{}', no URI", thing.getLabel(), currentEntry);
1517 playingQueue = false;
1520 updateMetaDataState(entry);
1521 setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
1523 if ((playingQueue || playing) && !(onlyplayone && oneplayed)) {
1524 logger.trace("Ready to play '{}' from queue", currentEntry);
1528 expectedTrackend = 0;
1532 playingQueue = true;
1535 // make the next entry available to renderers that support it
1537 UpnpEntry next = nextEntry;
1539 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1546 * Called before handling a pause CONTROL command. If we do not received PAUSED_PLAYBACK or STOPPED back within
1547 * timeout, we will revert to playing state. This takes care of renderers that cannot pause playback.
1549 private void checkPaused() {
1550 paused = upnpScheduler.schedule(this::resetPaused, config.responseTimeout, TimeUnit.MILLISECONDS);
1553 private void resetPaused() {
1554 updateState(CONTROL, PlayPauseType.PLAY);
1557 private void cancelCheckPaused() {
1558 ScheduledFuture<?> future = paused;
1559 if (future != null) {
1560 future.cancel(true);
1565 private void setExpectedTrackend() {
1566 expectedTrackend = Instant.now().toEpochMilli() + (trackDuration - trackPosition) * 1000
1567 - config.responseTimeout;
1571 * Update the current track position every second if the channel is linked.
1573 private void scheduleTrackPositionRefresh() {
1574 if (playingNotification) {
1578 cancelTrackPositionRefresh();
1579 if (!(isLinked(TRACK_POSITION) || isLinked(REL_TRACK_POSITION))) {
1580 // only get it once, so we can use the track end to correctly identify STOP pressed directly on renderer
1583 if (trackPositionRefresh == null) {
1584 trackPositionRefresh = upnpScheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1,
1590 private void cancelTrackPositionRefresh() {
1591 ScheduledFuture<?> refresh = trackPositionRefresh;
1593 if (refresh != null) {
1594 refresh.cancel(true);
1596 trackPositionRefresh = null;
1599 updateState(TRACK_POSITION, new QuantityType<>(trackPosition, Units.SECOND));
1600 int relPosition = (trackDuration != 0) ? trackPosition / trackDuration : 0;
1601 updateState(REL_TRACK_POSITION, new PercentType(relPosition));
1605 * Update metadata channels for media with data received from the Media Server or AV Transport.
1609 private void updateMetaDataState(UpnpEntry media) {
1610 // We don't want to update metadata if the metadata from the AVTransport is less complete than in the current
1612 boolean isCurrent = false;
1613 UpnpEntry entry = null;
1615 entry = currentEntry;
1618 logger.trace("Renderer {}, received media ID: {}", thing.getLabel(), media.getId());
1620 if ((entry != null) && entry.getId().equals(media.getId())) {
1621 logger.trace("Current ID: {}", entry.getId());
1625 // Sometimes we receive the media URL without the ID, then compare on URL
1626 String mediaRes = media.getRes().trim();
1627 String entryRes = (entry != null) ? entry.getRes().trim() : "";
1629 String mediaUrl = URLDecoder.decode(mediaRes, StandardCharsets.UTF_8);
1630 String entryUrl = URLDecoder.decode(entryRes, StandardCharsets.UTF_8);
1631 isCurrent = mediaUrl.equals(entryUrl);
1633 logger.trace("Current queue res: {}", entryRes);
1634 logger.trace("Updated media res: {}", mediaRes);
1637 logger.trace("Received meta data is for current entry: {}", isCurrent);
1639 if (!(isCurrent && media.getTitle().isEmpty())) {
1640 updateState(TITLE, StringType.valueOf(media.getTitle()));
1642 if (!(isCurrent && (media.getAlbum().isEmpty() || media.getAlbum().matches("Unknown.*")))) {
1643 updateState(ALBUM, StringType.valueOf(media.getAlbum()));
1646 && (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")))) {
1647 if (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")) {
1648 updateState(ALBUM_ART, UnDefType.UNDEF);
1650 State albumArt = HttpUtil.downloadImage(media.getAlbumArtUri());
1651 if (albumArt == null) {
1652 logger.debug("Failed to download the content of album art from URL {}", media.getAlbumArtUri());
1654 updateState(ALBUM_ART, UnDefType.UNDEF);
1657 updateState(ALBUM_ART, albumArt);
1661 if (!(isCurrent && (media.getCreator().isEmpty() || media.getCreator().matches("Unknown.*")))) {
1662 updateState(CREATOR, StringType.valueOf(media.getCreator()));
1664 if (!(isCurrent && (media.getArtist().isEmpty() || media.getArtist().matches("Unknown.*")))) {
1665 updateState(ARTIST, StringType.valueOf(media.getArtist()));
1667 if (!(isCurrent && (media.getPublisher().isEmpty() || media.getPublisher().matches("Unknown.*")))) {
1668 updateState(PUBLISHER, StringType.valueOf(media.getPublisher()));
1670 if (!(isCurrent && (media.getGenre().isEmpty() || media.getGenre().matches("Unknown.*")))) {
1671 updateState(GENRE, StringType.valueOf(media.getGenre()));
1673 if (!(isCurrent && (media.getOriginalTrackNumber() == null))) {
1674 Integer trackNumber = media.getOriginalTrackNumber();
1675 State trackNumberState = (trackNumber != null) ? new DecimalType(trackNumber) : UnDefType.UNDEF;
1676 updateState(TRACK_NUMBER, trackNumberState);
1680 private void clearMetaDataState() {
1681 updateState(TITLE, UnDefType.UNDEF);
1682 updateState(ALBUM, UnDefType.UNDEF);
1683 updateState(ALBUM_ART, UnDefType.UNDEF);
1684 updateState(CREATOR, UnDefType.UNDEF);
1685 updateState(ARTIST, UnDefType.UNDEF);
1686 updateState(PUBLISHER, UnDefType.UNDEF);
1687 updateState(GENRE, UnDefType.UNDEF);
1688 updateState(TRACK_NUMBER, UnDefType.UNDEF);
1692 * @return Audio formats supported by the renderer.
1694 public Set<AudioFormat> getSupportedAudioFormats() {
1695 return supportedAudioFormats;
1698 private void registerAudioSink() {
1699 if (audioSinkRegistered) {
1700 logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
1702 } else if (!upnpIOService.isRegistered(this)) {
1703 logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
1706 logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
1707 audioSinkReg.registerAudioSink(this);
1708 audioSinkRegistered = true;
1712 * @return UPnP sink definitions supported by the renderer.
1714 protected List<String> getSink() {