2 * Copyright (c) 2010-2023 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.UpnpAudioSinkReg;
45 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlBindingConfiguration;
46 import org.openhab.binding.upnpcontrol.internal.config.UpnpControlRendererConfiguration;
47 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntry;
48 import org.openhab.binding.upnpcontrol.internal.queue.UpnpEntryQueue;
49 import org.openhab.binding.upnpcontrol.internal.queue.UpnpFavorite;
50 import org.openhab.binding.upnpcontrol.internal.services.UpnpRenderingControlConfiguration;
51 import org.openhab.binding.upnpcontrol.internal.util.UpnpControlUtil;
52 import org.openhab.binding.upnpcontrol.internal.util.UpnpXMLParser;
53 import org.openhab.core.audio.AudioFormat;
54 import org.openhab.core.io.net.http.HttpUtil;
55 import org.openhab.core.io.transport.upnp.UpnpIOService;
56 import org.openhab.core.library.types.DecimalType;
57 import org.openhab.core.library.types.NextPreviousType;
58 import org.openhab.core.library.types.OnOffType;
59 import org.openhab.core.library.types.PercentType;
60 import org.openhab.core.library.types.PlayPauseType;
61 import org.openhab.core.library.types.QuantityType;
62 import org.openhab.core.library.types.RewindFastforwardType;
63 import org.openhab.core.library.types.StringType;
64 import org.openhab.core.library.unit.Units;
65 import org.openhab.core.thing.Channel;
66 import org.openhab.core.thing.ChannelUID;
67 import org.openhab.core.thing.Thing;
68 import org.openhab.core.thing.ThingStatus;
69 import org.openhab.core.thing.ThingStatusDetail;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.CommandOption;
72 import org.openhab.core.types.RefreshType;
73 import org.openhab.core.types.State;
74 import org.openhab.core.types.UnDefType;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
79 * The {@link UpnpRendererHandler} is responsible for handling commands sent to the UPnP Renderer. It extends
80 * {@link UpnpHandler} with UPnP renderer specific logic. It implements UPnP AVTransport and RenderingControl service
83 * @author Mark Herwege - Initial contribution
84 * @author Karel Goderis - Based on UPnP logic in Sonos binding
87 public class UpnpRendererHandler extends UpnpHandler {
89 private final Logger logger = LoggerFactory.getLogger(UpnpRendererHandler.class);
92 static final String RENDERING_CONTROL = "RenderingControl";
93 static final String AV_TRANSPORT = "AVTransport";
94 static final String INSTANCE_ID = "InstanceID";
96 private volatile boolean audioSupport;
97 protected volatile Set<AudioFormat> supportedAudioFormats = new HashSet<>();
98 private volatile boolean audioSinkRegistered;
100 private volatile UpnpAudioSinkReg audioSinkReg;
102 private volatile Set<UpnpServerHandler> serverHandlers = ConcurrentHashMap.newKeySet();
104 protected @NonNullByDefault({}) UpnpControlRendererConfiguration config;
105 private UpnpRenderingControlConfiguration renderingControlConfiguration = new UpnpRenderingControlConfiguration();
107 private volatile List<CommandOption> favoriteCommandOptionList = List.of();
108 private volatile List<CommandOption> playlistCommandOptionList = List.of();
110 private @NonNullByDefault({}) ChannelUID favoriteSelectChannelUID;
111 private @NonNullByDefault({}) ChannelUID playlistSelectChannelUID;
113 private volatile PercentType soundVolume = new PercentType();
114 private @Nullable volatile PercentType notificationVolume;
115 private volatile List<String> sink = new ArrayList<>();
117 private volatile String favoriteName = ""; // Currently selected favorite
119 private volatile boolean repeat;
120 private volatile boolean shuffle;
121 private volatile boolean onlyplayone; // Set to true if we only want to play one at a time
123 // Queue as received from server and current and next media entries for playback
124 private volatile UpnpEntryQueue currentQueue = new UpnpEntryQueue();
125 volatile @Nullable UpnpEntry currentEntry = null;
126 volatile @Nullable UpnpEntry nextEntry = null;
128 // Group of fields representing current state of player
129 private volatile String nowPlayingUri = ""; // Used to block waiting for setting URI when it is the same as current
130 // as some players will not send URI update when it is the same as
132 private volatile String transportState = ""; // Current transportState to be able to refresh the control
133 volatile boolean playerStopped; // Set if the player is stopped from OH command or code, allows to identify
134 // if STOP came from other source when receiving STOP state from GENA event
135 volatile boolean playing; // Set to false when a STOP is received, so we can filter two consecutive STOPs
136 // and not play next entry second time
137 private volatile @Nullable ScheduledFuture<?> paused; // Set when a pause command is given, to compensate for
138 // renderers that cannot pause playback
139 private volatile @Nullable CompletableFuture<Boolean> isSettingURI; // Set to wait for setting URI before starting
140 // to play or seeking
141 private volatile @Nullable CompletableFuture<Boolean> isStopping; // Set when stopping to be able to wait for stop
142 // confirmation for subsequent actions that need
143 // the player to be stopped
144 volatile boolean registeredQueue; // Set when registering a new queue. This allows to decide if we just
145 // need to play URI, or serve the first entry in a queue when a play
147 volatile boolean playingQueue; // Identifies if we are playing a queue received from a server. If so, a new
148 // queue received will be played after the currently playing entry
149 private volatile boolean oneplayed; // Set to true when the one entry is being played, allows to check if stop is
150 // needed when only playing one
151 volatile boolean playingNotification; // Set when playing a notification
152 private volatile @Nullable ScheduledFuture<?> playingNotificationFuture; // Set when playing a notification, allows
153 // timing out notification
154 private volatile String notificationUri = ""; // Used to check if the received URI is from the notification
155 private final Object notificationLock = new Object();
157 // Track position and duration fields
158 private volatile int trackDuration = 0;
159 private volatile int trackPosition = 0;
160 private volatile long expectedTrackend = 0;
161 private volatile @Nullable ScheduledFuture<?> trackPositionRefresh;
162 private volatile int posAtNotificationStart = 0;
164 public UpnpRendererHandler(Thing thing, UpnpIOService upnpIOService, UpnpAudioSinkReg audioSinkReg,
165 UpnpDynamicStateDescriptionProvider upnpStateDescriptionProvider,
166 UpnpDynamicCommandDescriptionProvider upnpCommandDescriptionProvider,
167 UpnpControlBindingConfiguration configuration) {
168 super(thing, upnpIOService, configuration, upnpStateDescriptionProvider, upnpCommandDescriptionProvider);
170 serviceSubscriptions.add(AV_TRANSPORT);
171 serviceSubscriptions.add(RENDERING_CONTROL);
173 this.audioSinkReg = audioSinkReg;
177 public void initialize() {
179 config = getConfigAs(UpnpControlRendererConfiguration.class);
180 if (config.seekStep < 1) {
183 logger.debug("Initializing handler for media renderer device {}", thing.getLabel());
185 Channel favoriteSelectChannel = thing.getChannel(FAVORITE_SELECT);
186 if (favoriteSelectChannel != null) {
187 favoriteSelectChannelUID = favoriteSelectChannel.getUID();
189 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", FAVORITE_SELECT);
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
193 Channel playlistSelectChannel = thing.getChannel(PLAYLIST_SELECT);
194 if (playlistSelectChannel != null) {
195 playlistSelectChannelUID = playlistSelectChannel.getUID();
197 String msg = String.format("@text/offline.channel-undefined [ \"%s\" ]", PLAYLIST_SELECT);
198 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, msg);
206 public void dispose() {
207 logger.debug("Disposing handler for media renderer device {}", thing.getLabel());
209 cancelTrackPositionRefresh();
211 CompletableFuture<Boolean> settingURI = isSettingURI;
212 if (settingURI != null) {
213 settingURI.complete(false);
220 protected void initJob() {
221 synchronized (jobLock) {
222 if (!upnpIOService.isRegistered(this)) {
223 String msg = String.format("@text/offline.device-not-registered [ \"%s\" ]", getUDN());
224 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
228 if (!ThingStatus.ONLINE.equals(thing.getStatus())) {
231 getCurrentConnectionInfo();
232 if (!checkForConnectionIds()) {
233 String msg = String.format("@text/offline.no-connection-ids [ \"%s\" ]", getUDN());
234 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, msg);
240 updateFavoritesList();
241 playlistsListChanged();
243 RemoteDevice device = getDevice();
244 if (device != null) { // The handler factory will update the device config later when it has not been
246 updateDeviceConfig(device);
249 updateStatus(ThingStatus.ONLINE);
252 if (!upnpSubscribed) {
259 public void updateDeviceConfig(RemoteDevice device) {
260 super.updateDeviceConfig(device);
262 UpnpRenderingControlConfiguration config = new UpnpRenderingControlConfiguration(device);
263 renderingControlConfiguration = config;
264 for (String audioChannel : config.audioChannels) {
265 createAudioChannels(audioChannel);
271 private void createAudioChannels(String audioChannel) {
272 UpnpRenderingControlConfiguration config = renderingControlConfiguration;
273 if (config.volume && !UPNP_MASTER.equals(audioChannel)) {
274 String name = audioChannel + "volume";
275 if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
276 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
278 String label = String.format("@text/channel.upnpcontrol.vendorvolume.label [ \"%s\" ]", audioChannel);
279 createChannel(name, label, "@text/channel.upnpcontrol.vendorvolume.description", ITEM_TYPE_VOLUME,
280 CHANNEL_TYPE_VOLUME);
283 if (config.mute && !UPNP_MASTER.equals(audioChannel)) {
284 String name = audioChannel + "mute";
285 if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
286 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
288 String label = String.format("@text/channel.upnpcontrol.vendormute.label [ \"%s\" ]", audioChannel);
289 createChannel(name, label, "@text/channel.upnpcontrol.vendormute.description", ITEM_TYPE_MUTE,
293 if (config.loudness) {
294 String name = (UPNP_MASTER.equals(audioChannel) ? "" : audioChannel) + "loudness";
295 if (UpnpChannelName.channelIdToUpnpChannelName(name) != null) {
296 createChannel(UpnpChannelName.channelIdToUpnpChannelName(name));
298 String label = String.format("@text/channel.upnpcontrol.vendorloudness.label [ \"%s\" ]", audioChannel);
299 createChannel(name, label, "@text/channel.upnpcontrol.vendorloudness.description", ITEM_TYPE_LOUDNESS,
300 CHANNEL_TYPE_LOUDNESS);
306 * Invoke Stop on UPnP AV Transport.
309 playerStopped = true;
312 CompletableFuture<Boolean> stopping = isStopping;
313 if (stopping != null) {
314 stopping.complete(false);
316 isStopping = new CompletableFuture<Boolean>(); // set this so we can check if stop confirmation has been
320 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
322 invokeAction(AV_TRANSPORT, "Stop", inputs);
326 * Invoke Play on UPnP AV Transport.
329 CompletableFuture<Boolean> settingURI = isSettingURI;
330 boolean uriSet = true;
332 if (settingURI != null) {
333 // wait for maximum 2.5s until the media URI is set before playing
334 uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
336 } catch (InterruptedException | ExecutionException | TimeoutException e) {
337 logger.debug("Timeout exception, media URI not yet set in renderer {}, trying to play anyway",
342 Map<String, String> inputs = new HashMap<>();
343 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
344 inputs.put("Speed", "1");
346 invokeAction(AV_TRANSPORT, "Play", inputs);
348 logger.debug("Cannot play, cancelled setting URI in the renderer {}", thing.getLabel());
353 * Invoke Pause on UPnP AV Transport.
355 protected void pause() {
356 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
358 invokeAction(AV_TRANSPORT, "Pause", inputs);
362 * Invoke Next on UPnP AV Transport.
364 protected void next() {
365 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
367 invokeAction(AV_TRANSPORT, "Next", inputs);
371 * Invoke Previous on UPnP AV Transport.
373 protected void previous() {
374 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
376 invokeAction(AV_TRANSPORT, "Previous", inputs);
380 * Invoke Seek on UPnP AV Transport.
382 * @param seekTarget relative position in current track, format HH:mm:ss
384 protected void seek(String seekTarget) {
385 CompletableFuture<Boolean> settingURI = isSettingURI;
386 boolean uriSet = true;
388 if (settingURI != null) {
389 // wait for maximum 2.5s until the media URI is set before seeking
390 uriSet = settingURI.get(config.responseTimeout, TimeUnit.MILLISECONDS);
392 } catch (InterruptedException | ExecutionException | TimeoutException e) {
393 logger.debug("Timeout exception, media URI not yet set in renderer {}, skipping seek", thing.getLabel());
398 Map<String, String> inputs = new HashMap<>();
399 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
400 inputs.put("Unit", "REL_TIME");
401 inputs.put("Target", seekTarget);
403 invokeAction(AV_TRANSPORT, "Seek", inputs);
405 logger.debug("Cannot seek, cancelled setting URI in the renderer {}", thing.getLabel());
410 * Invoke SetAVTransportURI on UPnP AV Transport.
415 public void setCurrentURI(String URI, String URIMetaData) {
417 uri = URLDecoder.decode(URI.trim(), StandardCharsets.UTF_8);
418 // Some renderers don't send a URI Last Changed event when the same URI is requested, so don't wait for it
419 // before starting to play
420 if (!uri.equals(nowPlayingUri) && !playingNotification) {
421 CompletableFuture<Boolean> settingURI = isSettingURI;
422 if (settingURI != null) {
423 settingURI.complete(false);
425 isSettingURI = new CompletableFuture<Boolean>(); // set this so we don't start playing when not finished
428 logger.debug("New URI {} is same as previous on renderer {}", nowPlayingUri, thing.getLabel());
431 Map<String, String> inputs = new HashMap<>();
432 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
433 inputs.put("CurrentURI", uri);
434 inputs.put("CurrentURIMetaData", URIMetaData);
436 invokeAction(AV_TRANSPORT, "SetAVTransportURI", inputs);
440 * Invoke SetNextAVTransportURI on UPnP AV Transport.
443 * @param nextURIMetaData
445 protected void setNextURI(String nextURI, String nextURIMetaData) {
446 Map<String, String> inputs = new HashMap<>();
447 inputs.put(INSTANCE_ID, Integer.toString(avTransportId));
448 inputs.put("NextURI", nextURI);
449 inputs.put("NextURIMetaData", nextURIMetaData);
451 invokeAction(AV_TRANSPORT, "SetNextAVTransportURI", inputs);
455 * Invoke GetTransportState on UPnP AV Transport.
456 * Result is received in {@link #onValueReceived}.
458 protected void getTransportState() {
459 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
461 invokeAction(AV_TRANSPORT, "GetTransportInfo", inputs);
465 * Invoke getPositionInfo on UPnP AV Transport.
466 * Result is received in {@link #onValueReceived}.
468 protected void getPositionInfo() {
469 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
471 invokeAction(AV_TRANSPORT, "GetPositionInfo", inputs);
475 * Invoke GetMediaInfo on UPnP AV Transport.
476 * Result is received in {@link #onValueReceived}.
478 protected void getMediaInfo() {
479 Map<String, String> inputs = Collections.singletonMap(INSTANCE_ID, Integer.toString(avTransportId));
481 invokeAction(AV_TRANSPORT, "smarthome:audio stream http://icecast.vrtcdn.be/stubru_tijdloze-high.mp3", inputs);
485 * Retrieves the current volume known to the control point, gets updated by GENA events or after UPnP Rendering
486 * Control GetVolume call. This method is used to retrieve volume with the
487 * {@link org.openhab.binding.upnpcontrol.internal.audiosink.UpnpAudioSink#getVolume UpnpAudioSink.getVolume}
490 * @return current volume
492 public PercentType getCurrentVolume() {
497 * Invoke GetVolume on UPnP Rendering Control.
498 * Result is received in {@link #onValueReceived}.
502 protected void getVolume(String channel) {
503 Map<String, String> inputs = new HashMap<>();
504 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
505 inputs.put("Channel", channel);
507 invokeAction(RENDERING_CONTROL, "GetVolume", inputs);
511 * Invoke SetVolume on UPnP Rendering Control.
516 protected void setVolume(String channel, PercentType volume) {
517 UpnpRenderingControlConfiguration config = renderingControlConfiguration;
519 long newVolume = volume.intValue() * config.maxvolume / 100;
520 Map<String, String> inputs = new HashMap<>();
521 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
522 inputs.put("Channel", channel);
523 inputs.put("DesiredVolume", String.valueOf(newVolume));
525 invokeAction(RENDERING_CONTROL, "SetVolume", inputs);
529 * Invoke SetVolume for Master channel on UPnP Rendering Control.
533 public void setVolume(PercentType volume) {
534 setVolume(UPNP_MASTER, volume);
538 * Invoke getMute on UPnP Rendering Control.
539 * Result is received in {@link #onValueReceived}.
543 protected void getMute(String channel) {
544 Map<String, String> inputs = new HashMap<>();
545 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
546 inputs.put("Channel", channel);
548 invokeAction(RENDERING_CONTROL, "GetMute", inputs);
552 * Invoke SetMute on UPnP Rendering Control.
557 protected void setMute(String channel, OnOffType mute) {
558 Map<String, String> inputs = new HashMap<>();
559 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
560 inputs.put("Channel", channel);
561 inputs.put("DesiredMute", mute == OnOffType.ON ? "1" : "0");
563 invokeAction(RENDERING_CONTROL, "SetMute", inputs);
567 * Invoke getMute on UPnP Rendering Control.
568 * Result is received in {@link #onValueReceived}.
572 protected void getLoudness(String channel) {
573 Map<String, String> inputs = new HashMap<>();
574 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
575 inputs.put("Channel", channel);
577 invokeAction(RENDERING_CONTROL, "GetLoudness", inputs);
581 * Invoke SetMute on UPnP Rendering Control.
586 protected void setLoudness(String channel, OnOffType mute) {
587 Map<String, String> inputs = new HashMap<>();
588 inputs.put(INSTANCE_ID, Integer.toString(rcsId));
589 inputs.put("Channel", channel);
590 inputs.put("DesiredLoudness", mute == OnOffType.ON ? "1" : "0");
592 invokeAction(RENDERING_CONTROL, "SetLoudness", inputs);
596 * Called from server handler for renderer to be able to send back status to server handler
600 protected void setServerHandler(UpnpServerHandler handler) {
601 logger.debug("Set server handler {} on renderer {}", handler.getThing().getLabel(), thing.getLabel());
602 serverHandlers.add(handler);
606 * Should be called from server handler when server stops serving this renderer
608 protected void unsetServerHandler() {
609 logger.debug("Unset server handler on renderer {}", thing.getLabel());
610 for (UpnpServerHandler handler : serverHandlers) {
611 Thing serverThing = handler.getThing();
612 Channel serverChannel;
613 for (String channel : SERVER_CONTROL_CHANNELS) {
614 if ((serverChannel = serverThing.getChannel(channel)) != null) {
615 handler.updateServerState(serverChannel.getUID(), UnDefType.UNDEF);
619 serverHandlers.remove(handler);
624 protected void updateState(ChannelUID channelUID, State state) {
625 // override to be able to propagate channel state updates to corresponding channels on the server
626 if (SERVER_CONTROL_CHANNELS.contains(channelUID.getId())) {
627 for (UpnpServerHandler handler : serverHandlers) {
628 Thing serverThing = handler.getThing();
629 Channel serverChannel = serverThing.getChannel(channelUID.getId());
630 if (serverChannel != null) {
631 logger.debug("Update server {} channel {} with state {} from renderer {}", serverThing.getLabel(),
632 state, channelUID, thing.getLabel());
633 handler.updateServerState(serverChannel.getUID(), state);
637 super.updateState(channelUID, state);
641 public void handleCommand(ChannelUID channelUID, Command command) {
642 logger.debug("Handle command {} for channel {} on renderer {}", command, channelUID, thing.getLabel());
644 String id = channelUID.getId();
646 if (id.endsWith("volume")) {
647 handleCommandVolume(command, id);
648 } else if (id.endsWith("mute")) {
649 handleCommandMute(command, id);
650 } else if (id.endsWith("loudness")) {
651 handleCommandLoudness(command, id);
655 handleCommandStop(command);
658 handleCommandControl(channelUID, command);
661 handleCommandRepeat(channelUID, command);
664 handleCommandShuffle(channelUID, command);
666 handleCommandOnlyPlayOne(channelUID, command);
669 handleCommandUri(channelUID, command);
671 case FAVORITE_SELECT:
672 handleCommandFavoriteSelect(command);
675 handleCommandFavorite(channelUID, command);
677 case FAVORITE_ACTION:
678 handleCommandFavoriteAction(command);
680 case PLAYLIST_SELECT:
681 handleCommandPlaylistSelect(command);
684 handleCommandTrackPosition(channelUID, command);
686 case REL_TRACK_POSITION:
687 handleCommandRelTrackPosition(channelUID, command);
695 private void handleCommandVolume(Command command, String id) {
696 if (command instanceof RefreshType) {
697 getVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""));
698 } else if (command instanceof PercentType) {
699 setVolume("volume".equals(id) ? UPNP_MASTER : id.replace("volume", ""), (PercentType) command);
703 private void handleCommandMute(Command command, String id) {
704 if (command instanceof RefreshType) {
705 getMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""));
706 } else if (command instanceof OnOffType) {
707 setMute("mute".equals(id) ? UPNP_MASTER : id.replace("mute", ""), (OnOffType) command);
711 private void handleCommandLoudness(Command command, String id) {
712 if (command instanceof RefreshType) {
713 getLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""));
714 } else if (command instanceof OnOffType) {
715 setLoudness("loudness".equals(id) ? UPNP_MASTER : id.replace("loudness", ""), (OnOffType) command);
719 private void handleCommandStop(Command command) {
720 if (OnOffType.ON.equals(command)) {
721 updateState(CONTROL, PlayPauseType.PAUSE);
723 updateState(TRACK_POSITION, new QuantityType<>(0, Units.SECOND));
727 private void handleCommandControl(ChannelUID channelUID, Command command) {
729 if (command instanceof RefreshType) {
730 state = transportState;
731 State newState = UnDefType.UNDEF;
732 if ("PLAYING".equals(state)) {
733 newState = PlayPauseType.PLAY;
734 } else if ("STOPPED".equals(state)) {
735 newState = PlayPauseType.PAUSE;
736 } else if ("PAUSED_PLAYBACK".equals(state)) {
737 newState = PlayPauseType.PAUSE;
739 updateState(channelUID, newState);
740 } else if (command instanceof PlayPauseType) {
741 if (PlayPauseType.PLAY.equals(command)) {
742 if (registeredQueue) {
743 registeredQueue = false;
750 } else if (PlayPauseType.PAUSE.equals(command)) {
754 } else if (command instanceof NextPreviousType) {
755 if (NextPreviousType.NEXT.equals(command)) {
757 } else if (NextPreviousType.PREVIOUS.equals(command)) {
760 } else if (command instanceof RewindFastforwardType) {
762 if (RewindFastforwardType.FASTFORWARD.equals(command)) {
763 pos = Integer.min(trackDuration, trackPosition + config.seekStep);
764 } else if (command == RewindFastforwardType.REWIND) {
765 pos = Integer.max(0, trackPosition - config.seekStep);
767 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
771 private void handleCommandRepeat(ChannelUID channelUID, Command command) {
772 if (command instanceof RefreshType) {
773 updateState(channelUID, OnOffType.from(repeat));
775 repeat = (OnOffType.ON.equals(command));
776 currentQueue.setRepeat(repeat);
777 updateState(channelUID, OnOffType.from(repeat));
778 logger.debug("Repeat set to {} for {}", repeat, thing.getLabel());
782 private void handleCommandShuffle(ChannelUID channelUID, Command command) {
783 if (command instanceof RefreshType) {
784 updateState(channelUID, OnOffType.from(shuffle));
786 shuffle = (OnOffType.ON.equals(command));
787 currentQueue.setShuffle(shuffle);
791 updateState(channelUID, OnOffType.from(shuffle));
792 logger.debug("Shuffle set to {} for {}", shuffle, thing.getLabel());
796 private void handleCommandOnlyPlayOne(ChannelUID channelUID, Command command) {
797 if (command instanceof RefreshType) {
798 updateState(channelUID, OnOffType.from(onlyplayone));
800 onlyplayone = (OnOffType.ON.equals(command));
801 oneplayed = (onlyplayone && playing) ? true : false;
805 UpnpEntry next = nextEntry;
807 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
810 updateState(channelUID, OnOffType.from(onlyplayone));
811 logger.debug("OnlyPlayOne set to {} for {}", onlyplayone, thing.getLabel());
815 private void handleCommandUri(ChannelUID channelUID, Command command) {
816 if (command instanceof RefreshType) {
817 updateState(channelUID, StringType.valueOf(nowPlayingUri));
818 } else if (command instanceof StringType) {
819 setCurrentURI(command.toString(), "");
824 private void handleCommandFavoriteSelect(Command command) {
825 if (command instanceof StringType) {
826 favoriteName = command.toString();
827 updateState(FAVORITE, StringType.valueOf(favoriteName));
832 private void handleCommandFavorite(ChannelUID channelUID, Command command) {
833 if (command instanceof StringType) {
834 favoriteName = command.toString();
835 if (favoriteCommandOptionList.contains(new CommandOption(favoriteName, favoriteName))) {
839 updateState(channelUID, StringType.valueOf(favoriteName));
842 private void handleCommandFavoriteAction(Command command) {
843 if (command instanceof StringType) {
844 switch (command.toString()) {
846 handleCommandFavoriteSave();
849 handleCommandFavoriteDelete();
855 private void handleCommandFavoriteSave() {
856 if (!favoriteName.isEmpty()) {
857 UpnpFavorite favorite = new UpnpFavorite(favoriteName, nowPlayingUri, currentEntry);
858 favorite.saveFavorite(favoriteName, bindingConfig.path);
859 updateFavoritesList();
863 private void handleCommandFavoriteDelete() {
864 if (!favoriteName.isEmpty()) {
865 UpnpControlUtil.deleteFavorite(favoriteName, bindingConfig.path);
866 updateFavoritesList();
867 updateState(FAVORITE, UnDefType.UNDEF);
871 private void handleCommandPlaylistSelect(Command command) {
872 if (command instanceof StringType) {
873 String playlistName = command.toString();
874 UpnpEntryQueue queue = new UpnpEntryQueue();
875 queue.restoreQueue(playlistName, null, bindingConfig.path);
876 registerQueue(queue);
883 private void handleCommandTrackPosition(ChannelUID channelUID, Command command) {
884 if (command instanceof RefreshType) {
885 updateState(channelUID, new QuantityType<>(trackPosition, Units.SECOND));
886 } else if (command instanceof QuantityType<?>) {
887 QuantityType<?> position = ((QuantityType<?>) command).toUnit(Units.SECOND);
888 if (position != null) {
889 int pos = Integer.min(trackDuration, position.intValue());
890 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
895 private void handleCommandRelTrackPosition(ChannelUID channelUID, Command command) {
896 if (command instanceof RefreshType) {
897 int relPosition = (trackDuration != 0) ? (trackPosition * 100) / trackDuration : 0;
898 updateState(channelUID, new PercentType(relPosition));
899 } else if (command instanceof PercentType) {
900 int pos = ((PercentType) command).intValue() * trackDuration / 100;
901 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
906 * Set the volume for notifications.
910 public void setNotificationVolume(PercentType volume) {
911 notificationVolume = volume;
915 * Play a notification. Previous state of the renderer will resume at the end of the notification, or after the
916 * maximum notification duration as defined in the renderer parameters.
918 * @param URI for notification sound
920 public void playNotification(String URI) {
921 synchronized (notificationLock) {
923 logger.debug("UPnP device {} received empty notification URI", thing.getLabel());
927 notificationUri = URI;
929 logger.debug("UPnP device {} playing notification {}", thing.getLabel(), URI);
931 cancelTrackPositionRefresh();
934 cancelPlayingNotificationFuture();
936 if (config.maxNotificationDuration > 0) {
937 playingNotificationFuture = upnpScheduler.schedule(this::stop, config.maxNotificationDuration,
940 playingNotification = true;
942 setCurrentURI(URI, "");
944 PercentType volume = notificationVolume;
945 setVolume(volume == null
946 ? new PercentType(Math.min(100,
947 Math.max(0, (100 + config.notificationVolumeAdjustment) * soundVolume.intValue() / 100)))
950 CompletableFuture<Boolean> stopping = isStopping;
952 if (stopping != null) {
953 // wait for maximum 2.5s until the renderer stopped before playing
954 stopping.get(config.responseTimeout, TimeUnit.MILLISECONDS);
956 } catch (InterruptedException | ExecutionException | TimeoutException e) {
957 logger.debug("Timeout exception, renderer {} didn't stop yet, trying to play anyway", thing.getLabel());
963 private void cancelPlayingNotificationFuture() {
964 ScheduledFuture<?> future = playingNotificationFuture;
965 if (future != null) {
967 playingNotificationFuture = null;
971 private void resumeAfterNotification() {
972 synchronized (notificationLock) {
973 logger.debug("UPnP device {} resume after playing notification", thing.getLabel());
975 setCurrentURI(nowPlayingUri, "");
976 setVolume(soundVolume);
978 cancelPlayingNotificationFuture();
980 playingNotification = false;
981 notificationVolume = null;
982 notificationUri = "";
985 int pos = posAtNotificationStart;
986 seek(String.format("%02d:%02d:%02d", pos / 3600, (pos % 3600) / 60, pos % 60));
989 posAtNotificationStart = 0;
993 private void playFavorite() {
994 UpnpFavorite favorite = new UpnpFavorite(favoriteName, bindingConfig.path);
995 String uri = favorite.getUri();
996 UpnpEntry entry = favorite.getUpnpEntry();
997 if (!uri.isEmpty()) {
998 String metadata = "";
1000 metadata = UpnpXMLParser.compileMetadataString(entry);
1002 setCurrentURI(uri, metadata);
1007 void updateFavoritesList() {
1008 favoriteCommandOptionList = UpnpControlUtil.favorites(bindingConfig.path).stream()
1009 .map(p -> (new CommandOption(p, p))).collect(Collectors.toList());
1010 updateCommandDescription(favoriteSelectChannelUID, favoriteCommandOptionList);
1014 public void playlistsListChanged() {
1015 playlistCommandOptionList = UpnpControlUtil.playlists().stream().map(p -> (new CommandOption(p, p)))
1016 .collect(Collectors.toList());
1017 updateCommandDescription(playlistSelectChannelUID, playlistCommandOptionList);
1021 public void onStatusChanged(boolean status) {
1023 removeSubscriptions();
1025 updateState(CONTROL, PlayPauseType.PAUSE);
1026 cancelTrackPositionRefresh();
1028 super.onStatusChanged(status);
1032 protected @Nullable String preProcessValueReceived(Map<String, String> inputs, @Nullable String variable,
1033 @Nullable String value, @Nullable String service, @Nullable String action) {
1034 if (variable == null) {
1038 case "CurrentVolume":
1039 return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Volume";
1041 return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Mute";
1042 case "CurrentLoudness":
1043 return (inputs.containsKey("Channel") ? inputs.get("Channel") : UPNP_MASTER) + "Loudness";
1051 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
1052 if (logger.isTraceEnabled()) {
1053 logger.trace("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
1054 variable, value, service);
1056 if (logger.isDebugEnabled() && !("AbsTime".equals(variable) || "RelCount".equals(variable)
1057 || "RelTime".equals(variable) || "AbsCount".equals(variable) || "Track".equals(variable)
1058 || "TrackDuration".equals(variable))) {
1059 // don't log all variables received when updating the track position every second
1060 logger.debug("UPnP device {} received variable {} with value {} from service {}", thing.getLabel(),
1061 variable, value, service);
1064 if (variable == null) {
1068 if (variable.endsWith("Volume")) {
1069 onValueReceivedVolume(variable, value);
1070 } else if (variable.endsWith("Mute")) {
1071 onValueReceivedMute(variable, value);
1072 } else if (variable.endsWith("Loudness")) {
1073 onValueReceivedLoudness(variable, value);
1077 onValueReceivedLastChange(value, service);
1079 case "CurrentTransportState":
1080 case "TransportState":
1081 onValueReceivedTransportState(value);
1083 case "CurrentTrackURI":
1085 onValueReceivedCurrentURI(value);
1087 case "CurrentTrackMetaData":
1088 case "CurrentURIMetaData":
1089 onValueReceivedCurrentMetaData(value);
1091 case "NextAVTransportURIMetaData":
1092 case "NextURIMetaData":
1093 onValueReceivedNextMetaData(value);
1095 case "CurrentTrackDuration":
1096 case "TrackDuration":
1097 onValueReceivedDuration(value);
1100 onValueReceivedRelTime(value);
1103 super.onValueReceived(variable, value, service);
1109 private void onValueReceivedVolume(String variable, @Nullable String value) {
1110 if (value != null && !value.isEmpty()) {
1111 UpnpRenderingControlConfiguration config = renderingControlConfiguration;
1113 long volume = Long.valueOf(value);
1114 volume = volume * 100 / config.maxvolume;
1116 String upnpChannel = variable.replace("Volume", "volume").replace("Master", "");
1117 updateState(upnpChannel, new PercentType((int) volume));
1119 if (!playingNotification && "volume".equals(upnpChannel)) {
1120 soundVolume = new PercentType((int) volume);
1125 private void onValueReceivedMute(String variable, @Nullable String value) {
1126 if (value != null && !value.isEmpty()) {
1127 String upnpChannel = variable.replace("Mute", "mute").replace("Master", "");
1128 updateState(upnpChannel,
1129 ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
1133 private void onValueReceivedLoudness(String variable, @Nullable String value) {
1134 if (value != null && !value.isEmpty()) {
1135 String upnpChannel = variable.replace("Loudness", "loudness").replace("Master", "");
1136 updateState(upnpChannel,
1137 ("1".equals(value) || "true".equals(value.toLowerCase())) ? OnOffType.ON : OnOffType.OFF);
1141 private void onValueReceivedLastChange(@Nullable String value, @Nullable String service) {
1142 // This is returned from a GENA subscription. The jupnp library does not allow receiving new GENA subscription
1143 // messages as long as this thread has not finished. As we may trigger long running processes based on this
1144 // result, we run it in a separate thread.
1145 upnpScheduler.submit(() -> {
1146 // pre-process some variables, eg XML processing
1147 if (value != null && !value.isEmpty()) {
1148 if (AV_TRANSPORT.equals(service)) {
1149 Map<String, String> parsedValues = UpnpXMLParser.getAVTransportFromXML(value);
1150 for (Map.Entry<String, String> entrySet : parsedValues.entrySet()) {
1151 switch (entrySet.getKey()) {
1152 case "TransportState":
1153 // Update the transport state after the update of the media information
1154 // to not break the notification mechanism
1156 case "AVTransportURI":
1157 onValueReceived("CurrentTrackURI", entrySet.getValue(), service);
1159 case "AVTransportURIMetaData":
1160 onValueReceived("CurrentTrackMetaData", entrySet.getValue(), service);
1163 onValueReceived(entrySet.getKey(), entrySet.getValue(), service);
1166 if (parsedValues.containsKey("TransportState")) {
1167 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
1169 } else if (RENDERING_CONTROL.equals(service)) {
1170 Map<String, @Nullable String> parsedValues = UpnpXMLParser.getRenderingControlFromXML(value);
1171 for (String parsedValue : parsedValues.keySet()) {
1172 onValueReceived(parsedValue, parsedValues.get(parsedValue), RENDERING_CONTROL);
1179 private void onValueReceivedTransportState(@Nullable String value) {
1180 transportState = (value == null) ? "" : value;
1182 if ("STOPPED".equals(value)) {
1183 CompletableFuture<Boolean> stopping = isStopping;
1184 if (stopping != null) {
1185 stopping.complete(true); // We have received stop confirmation
1189 if (playingNotification) {
1190 resumeAfterNotification();
1194 cancelCheckPaused();
1195 updateState(CONTROL, PlayPauseType.PAUSE);
1196 cancelTrackPositionRefresh();
1197 // Only go to next for first STOP command, then wait until we received PLAYING before moving
1198 // to next (avoids issues with renderers sending multiple stop states)
1202 // playerStopped is true if stop came from openHAB. This allows us to identify if we played to the
1203 // end of an entry, because STOP would come from the player and not from openHAB. We should then
1204 // move to the next entry if the queue is not at the end already.
1205 if (!playerStopped) {
1206 if (Instant.now().toEpochMilli() >= expectedTrackend) {
1207 // If we are receiving track duration info, we know when the track is expected to end. If we
1208 // received STOP before track end, and it is not coming from openHAB, it must have been stopped
1209 // from the renderer directly, and we do not want to play the next entry.
1214 } else if (playingQueue) {
1215 playingQueue = false;
1218 } else if ("PLAYING".equals(value)) {
1219 if (playingNotification) {
1223 playerStopped = false;
1225 registeredQueue = false; // reset queue registration flag as we are playing something
1226 updateState(CONTROL, PlayPauseType.PLAY);
1227 scheduleTrackPositionRefresh();
1228 } else if ("PAUSED_PLAYBACK".equals(value)) {
1229 cancelCheckPaused();
1230 updateState(CONTROL, PlayPauseType.PAUSE);
1231 } else if ("NO_MEDIA_PRESENT".equals(value)) {
1232 updateState(CONTROL, UnDefType.UNDEF);
1236 private void onValueReceivedCurrentURI(@Nullable String value) {
1237 CompletableFuture<Boolean> settingURI = isSettingURI;
1238 if (settingURI != null) {
1239 settingURI.complete(true); // We have received current URI, so can allow play to start
1242 UpnpEntry current = currentEntry;
1243 UpnpEntry next = nextEntry;
1246 String currentUri = "";
1247 String nextUri = "";
1248 if (value != null) {
1249 uri = URLDecoder.decode(value.trim(), StandardCharsets.UTF_8);
1251 if (current != null) {
1252 currentUri = URLDecoder.decode(current.getRes().trim(), StandardCharsets.UTF_8);
1255 nextUri = URLDecoder.decode(next.getRes(), StandardCharsets.UTF_8);
1258 if (playingNotification && uri.equals(notificationUri)) {
1259 // No need to update anything more if this is for playing a notification
1263 nowPlayingUri = uri;
1264 updateState(URI, StringType.valueOf(uri));
1266 logger.trace("Renderer {} received URI: {}", thing.getLabel(), uri);
1267 logger.trace("Renderer {} current URI: {}, equal to received URI {}", thing.getLabel(), currentUri,
1268 uri.equals(currentUri));
1269 logger.trace("Renderer {} next URI: {}", thing.getLabel(), nextUri);
1271 if (!uri.equals(currentUri)) {
1272 if ((next != null) && uri.equals(nextUri)) {
1273 // Renderer advanced to next entry independent of openHAB UPnP control point.
1274 // Advance in the queue to keep proper position status.
1275 // Make the next entry available to renderers that support it.
1276 logger.trace("Renderer {} moved from '{}' to next entry '{}' in queue", thing.getLabel(), current,
1278 currentEntry = currentQueue.next();
1279 nextEntry = currentQueue.get(currentQueue.nextIndex());
1280 logger.trace("Renderer {} auto move forward, current queue index: {}", thing.getLabel(),
1281 currentQueue.index());
1283 updateMetaDataState(next);
1285 // look one further to get next entry for next URI
1287 if ((next != null) && !onlyplayone) {
1288 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1291 // A new entry is being served that does not match the next entry in the queue. This can be because a
1292 // sound or stream is being played through an action, or another control point started a new entry.
1293 // We should clear the metadata in this case and wait for new metadata to arrive.
1294 clearMetaDataState();
1299 private void onValueReceivedCurrentMetaData(@Nullable String value) {
1300 if (playingNotification) {
1301 // Don't update metadata when playing notification
1305 if (value != null && !value.isEmpty()) {
1306 List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
1307 if (!list.isEmpty()) {
1308 updateMetaDataState(list.get(0));
1312 clearMetaDataState();
1315 private void onValueReceivedNextMetaData(@Nullable String value) {
1316 if (value != null && !value.isEmpty() && !"NOT_IMPLEMENTED".equals(value)) {
1317 List<UpnpEntry> list = UpnpXMLParser.getEntriesFromXML(value);
1318 if (!list.isEmpty()) {
1319 nextEntry = list.get(0);
1324 private void onValueReceivedDuration(@Nullable String value) {
1325 // track duration and track position have format H+:MM:SS[.F+] or H+:MM:SS[.F0/F1]. We are not
1326 // interested in the fractional seconds, so drop everything after . and calculate in seconds.
1327 if (value == null || "NOT_IMPLEMENTED".equals(value)) {
1329 updateState(TRACK_DURATION, UnDefType.UNDEF);
1330 updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1333 trackDuration = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
1334 .reduce(0, (n, m) -> n * 60 + m);
1335 updateState(TRACK_DURATION, new QuantityType<>(trackDuration, Units.SECOND));
1336 } catch (NumberFormatException e) {
1337 logger.debug("Illegal format for track duration {}", value);
1341 setExpectedTrackend();
1344 private void onValueReceivedRelTime(@Nullable String value) {
1345 if (value == null || "NOT_IMPLEMENTED".equals(value)) {
1347 updateState(TRACK_POSITION, UnDefType.UNDEF);
1348 updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1351 trackPosition = Arrays.stream(value.split("\\.")[0].split(":")).mapToInt(n -> Integer.parseInt(n))
1352 .reduce(0, (n, m) -> n * 60 + m);
1353 updateState(TRACK_POSITION, new QuantityType<>(trackPosition, Units.SECOND));
1354 int relPosition = (trackDuration != 0) ? trackPosition * 100 / trackDuration : 0;
1355 updateState(REL_TRACK_POSITION, new PercentType(relPosition));
1356 } catch (NumberFormatException e) {
1357 logger.trace("Illegal format for track position {}", value);
1362 if (playingNotification) {
1363 posAtNotificationStart = trackPosition;
1366 setExpectedTrackend();
1370 protected void updateProtocolInfo(String value) {
1372 supportedAudioFormats.clear();
1373 audioSupport = false;
1375 sink.addAll(Arrays.asList(value.split(",")));
1377 for (String protocol : sink) {
1378 Matcher matcher = PROTOCOL_PATTERN.matcher(protocol);
1379 if (matcher.find()) {
1380 String format = matcher.group(1);
1385 supportedAudioFormats.add(AudioFormat.MP3);
1389 supportedAudioFormats.add(AudioFormat.WAV);
1392 audioSupport = audioSupport || Pattern.matches("audio.*", format);
1397 logger.debug("Renderer {} supports audio", thing.getLabel());
1398 registerAudioSink();
1402 private void clearCurrentEntry() {
1403 clearMetaDataState();
1406 updateState(TRACK_DURATION, UnDefType.UNDEF);
1408 updateState(TRACK_POSITION, UnDefType.UNDEF);
1409 updateState(REL_TRACK_POSITION, UnDefType.UNDEF);
1411 currentEntry = null;
1415 * Register a new queue with media entries to the renderer. Set the next position at the first entry in the list.
1416 * If the renderer is currently playing, set the first entry in the list as the next media. If not playing, set it
1421 protected void registerQueue(UpnpEntryQueue queue) {
1422 if (currentQueue.equals(queue)) {
1423 // We get the same queue, so do nothing
1427 logger.debug("Registering queue on renderer {}", thing.getLabel());
1429 registeredQueue = true;
1430 currentQueue = queue;
1431 currentQueue.setRepeat(repeat);
1432 currentQueue.setShuffle(shuffle);
1434 nextEntry = currentQueue.get(currentQueue.nextIndex());
1435 UpnpEntry next = nextEntry;
1436 if ((next != null) && !onlyplayone) {
1437 // make the next entry available to renderers that support it
1438 logger.trace("Renderer {} still playing, set new queue as next entry", thing.getLabel());
1439 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1442 resetToStartQueue();
1447 * Move to next position in queue and start playing.
1449 private void serveNext() {
1450 if (currentQueue.hasNext()) {
1451 currentEntry = currentQueue.next();
1452 nextEntry = currentQueue.get(currentQueue.nextIndex());
1453 logger.debug("Serve next media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
1454 logger.trace("Serve next, current queue index: {}", currentQueue.index());
1458 logger.debug("Cannot serve next, end of queue on renderer {}", thing.getLabel());
1459 resetToStartQueue();
1464 * Move to previous position in queue and start playing.
1466 private void servePrevious() {
1467 if (currentQueue.hasPrevious()) {
1468 currentEntry = currentQueue.previous();
1469 nextEntry = currentQueue.get(currentQueue.nextIndex());
1470 logger.debug("Serve previous media '{}' from queue on renderer {}", currentEntry, thing.getLabel());
1471 logger.trace("Serve previous, current queue index: {}", currentQueue.index());
1475 logger.debug("Cannot serve previous, already at start of queue on renderer {}", thing.getLabel());
1476 resetToStartQueue();
1480 private void resetToStartQueue() {
1481 logger.trace("Reset to start queue on renderer {}", thing.getLabel());
1483 playingQueue = false;
1484 registeredQueue = true;
1488 currentQueue.resetIndex(); // reset to beginning of queue
1489 currentEntry = currentQueue.next();
1490 nextEntry = currentQueue.get(currentQueue.nextIndex());
1491 logger.trace("Reset queue, current queue index: {}", currentQueue.index());
1492 UpnpEntry current = currentEntry;
1493 if (current != null) {
1494 clearMetaDataState();
1495 updateMetaDataState(current);
1496 setCurrentURI(current.getRes(), UpnpXMLParser.compileMetadataString(current));
1498 clearCurrentEntry();
1501 UpnpEntry next = nextEntry;
1504 } else if (next != null) {
1505 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1510 * Serve media from a queue and play immediately when already playing.
1514 private void serve() {
1515 logger.trace("Serve media on renderer {}", thing.getLabel());
1517 UpnpEntry entry = currentEntry;
1518 if (entry != null) {
1519 clearMetaDataState();
1520 String res = entry.getRes();
1521 if (res.isEmpty()) {
1522 logger.debug("Renderer {} cannot serve media '{}', no URI", thing.getLabel(), currentEntry);
1523 playingQueue = false;
1526 updateMetaDataState(entry);
1527 setCurrentURI(res, UpnpXMLParser.compileMetadataString(entry));
1529 if ((playingQueue || playing) && !(onlyplayone && oneplayed)) {
1530 logger.trace("Ready to play '{}' from queue", currentEntry);
1534 expectedTrackend = 0;
1538 playingQueue = true;
1541 // make the next entry available to renderers that support it
1543 UpnpEntry next = nextEntry;
1545 setNextURI(next.getRes(), UpnpXMLParser.compileMetadataString(next));
1552 * Called before handling a pause CONTROL command. If we do not received PAUSED_PLAYBACK or STOPPED back within
1553 * timeout, we will revert to playing state. This takes care of renderers that cannot pause playback.
1555 private void checkPaused() {
1556 paused = upnpScheduler.schedule(this::resetPaused, config.responseTimeout, TimeUnit.MILLISECONDS);
1559 private void resetPaused() {
1560 updateState(CONTROL, PlayPauseType.PLAY);
1563 private void cancelCheckPaused() {
1564 ScheduledFuture<?> future = paused;
1565 if (future != null) {
1566 future.cancel(true);
1571 private void setExpectedTrackend() {
1572 expectedTrackend = Instant.now().toEpochMilli() + (trackDuration - trackPosition) * 1000
1573 - config.responseTimeout;
1577 * Update the current track position every second if the channel is linked.
1579 private void scheduleTrackPositionRefresh() {
1580 if (playingNotification) {
1584 cancelTrackPositionRefresh();
1585 if (!(isLinked(TRACK_POSITION) || isLinked(REL_TRACK_POSITION))) {
1586 // only get it once, so we can use the track end to correctly identify STOP pressed directly on renderer
1589 if (trackPositionRefresh == null) {
1590 trackPositionRefresh = upnpScheduler.scheduleWithFixedDelay(this::getPositionInfo, 1, 1,
1596 private void cancelTrackPositionRefresh() {
1597 ScheduledFuture<?> refresh = trackPositionRefresh;
1599 if (refresh != null) {
1600 refresh.cancel(true);
1602 trackPositionRefresh = null;
1605 updateState(TRACK_POSITION, new QuantityType<>(trackPosition, Units.SECOND));
1606 int relPosition = (trackDuration != 0) ? trackPosition / trackDuration : 0;
1607 updateState(REL_TRACK_POSITION, new PercentType(relPosition));
1611 * Update metadata channels for media with data received from the Media Server or AV Transport.
1615 private void updateMetaDataState(UpnpEntry media) {
1616 // We don't want to update metadata if the metadata from the AVTransport is less complete than in the current
1618 boolean isCurrent = false;
1619 UpnpEntry entry = null;
1621 entry = currentEntry;
1624 logger.trace("Renderer {}, received media ID: {}", thing.getLabel(), media.getId());
1626 if ((entry != null) && entry.getId().equals(media.getId())) {
1627 logger.trace("Current ID: {}", entry.getId());
1631 // Sometimes we receive the media URL without the ID, then compare on URL
1632 String mediaRes = media.getRes().trim();
1633 String entryRes = (entry != null) ? entry.getRes().trim() : "";
1635 String mediaUrl = URLDecoder.decode(mediaRes, StandardCharsets.UTF_8);
1636 String entryUrl = URLDecoder.decode(entryRes, StandardCharsets.UTF_8);
1637 isCurrent = mediaUrl.equals(entryUrl);
1639 logger.trace("Current queue res: {}", entryRes);
1640 logger.trace("Updated media res: {}", mediaRes);
1643 logger.trace("Received meta data is for current entry: {}", isCurrent);
1645 if (!(isCurrent && media.getTitle().isEmpty())) {
1646 updateState(TITLE, StringType.valueOf(media.getTitle()));
1648 if (!(isCurrent && (media.getAlbum().isEmpty() || media.getAlbum().matches("Unknown.*")))) {
1649 updateState(ALBUM, StringType.valueOf(media.getAlbum()));
1652 && (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")))) {
1653 if (media.getAlbumArtUri().isEmpty() || media.getAlbumArtUri().contains("DefaultAlbumCover")) {
1654 updateState(ALBUM_ART, UnDefType.UNDEF);
1656 State albumArt = HttpUtil.downloadImage(media.getAlbumArtUri());
1657 if (albumArt == null) {
1658 logger.debug("Failed to download the content of album art from URL {}", media.getAlbumArtUri());
1660 updateState(ALBUM_ART, UnDefType.UNDEF);
1663 updateState(ALBUM_ART, albumArt);
1667 if (!(isCurrent && (media.getCreator().isEmpty() || media.getCreator().matches("Unknown.*")))) {
1668 updateState(CREATOR, StringType.valueOf(media.getCreator()));
1670 if (!(isCurrent && (media.getArtist().isEmpty() || media.getArtist().matches("Unknown.*")))) {
1671 updateState(ARTIST, StringType.valueOf(media.getArtist()));
1673 if (!(isCurrent && (media.getPublisher().isEmpty() || media.getPublisher().matches("Unknown.*")))) {
1674 updateState(PUBLISHER, StringType.valueOf(media.getPublisher()));
1676 if (!(isCurrent && (media.getGenre().isEmpty() || media.getGenre().matches("Unknown.*")))) {
1677 updateState(GENRE, StringType.valueOf(media.getGenre()));
1679 if (!(isCurrent && (media.getOriginalTrackNumber() == null))) {
1680 Integer trackNumber = media.getOriginalTrackNumber();
1681 State trackNumberState = (trackNumber != null) ? new DecimalType(trackNumber) : UnDefType.UNDEF;
1682 updateState(TRACK_NUMBER, trackNumberState);
1686 private void clearMetaDataState() {
1687 updateState(TITLE, UnDefType.UNDEF);
1688 updateState(ALBUM, UnDefType.UNDEF);
1689 updateState(ALBUM_ART, UnDefType.UNDEF);
1690 updateState(CREATOR, UnDefType.UNDEF);
1691 updateState(ARTIST, UnDefType.UNDEF);
1692 updateState(PUBLISHER, UnDefType.UNDEF);
1693 updateState(GENRE, UnDefType.UNDEF);
1694 updateState(TRACK_NUMBER, UnDefType.UNDEF);
1698 * @return Audio formats supported by the renderer.
1700 public Set<AudioFormat> getSupportedAudioFormats() {
1701 return supportedAudioFormats;
1704 private void registerAudioSink() {
1705 if (audioSinkRegistered) {
1706 logger.debug("Audio Sink already registered for renderer {}", thing.getLabel());
1708 } else if (!upnpIOService.isRegistered(this)) {
1709 logger.debug("Audio Sink registration for renderer {} failed, no service", thing.getLabel());
1712 logger.debug("Registering Audio Sink for renderer {}", thing.getLabel());
1713 audioSinkReg.registerAudioSink(this);
1714 audioSinkRegistered = true;
1718 * @return UPnP sink definitions supported by the renderer.
1720 protected List<String> getSink() {