2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.sonos.internal.handler;
15 import static org.openhab.binding.sonos.internal.SonosBindingConstants.*;
17 import java.io.IOException;
18 import java.net.MalformedURLException;
20 import java.text.ParseException;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.Arrays;
24 import java.util.Calendar;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.Date;
28 import java.util.HashMap;
29 import java.util.List;
31 import java.util.TimeZone;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.openhab.binding.sonos.internal.SonosAlarm;
38 import org.openhab.binding.sonos.internal.SonosBindingConstants;
39 import org.openhab.binding.sonos.internal.SonosEntry;
40 import org.openhab.binding.sonos.internal.SonosMetaData;
41 import org.openhab.binding.sonos.internal.SonosMusicService;
42 import org.openhab.binding.sonos.internal.SonosResourceMetaData;
43 import org.openhab.binding.sonos.internal.SonosStateDescriptionOptionProvider;
44 import org.openhab.binding.sonos.internal.SonosXMLParser;
45 import org.openhab.binding.sonos.internal.SonosZoneGroup;
46 import org.openhab.binding.sonos.internal.SonosZonePlayerState;
47 import org.openhab.binding.sonos.internal.config.ZonePlayerConfiguration;
48 import org.openhab.core.io.net.http.HttpUtil;
49 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
50 import org.openhab.core.io.transport.upnp.UpnpIOService;
51 import org.openhab.core.library.types.DecimalType;
52 import org.openhab.core.library.types.IncreaseDecreaseType;
53 import org.openhab.core.library.types.NextPreviousType;
54 import org.openhab.core.library.types.OnOffType;
55 import org.openhab.core.library.types.OpenClosedType;
56 import org.openhab.core.library.types.PercentType;
57 import org.openhab.core.library.types.PlayPauseType;
58 import org.openhab.core.library.types.RawType;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.library.types.UpDownType;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingRegistry;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.ThingTypeUID;
67 import org.openhab.core.thing.ThingUID;
68 import org.openhab.core.thing.binding.BaseThingHandler;
69 import org.openhab.core.thing.binding.ThingHandler;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.State;
73 import org.openhab.core.types.StateOption;
74 import org.openhab.core.types.UnDefType;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
79 * The {@link ZonePlayerHandler} is responsible for handling commands, which are
80 * sent to one of the channels.
82 * @author Karel Goderis - Initial contribution
85 public class ZonePlayerHandler extends BaseThingHandler implements UpnpIOParticipant {
87 private static final String ANALOG_LINE_IN_URI = "x-rincon-stream:";
88 private static final String OPTICAL_LINE_IN_URI = "x-sonos-htastream:";
89 private static final String QUEUE_URI = "x-rincon-queue:";
90 private static final String GROUP_URI = "x-rincon:";
91 private static final String STREAM_URI = "x-sonosapi-stream:";
92 private static final String RADIO_URI = "x-sonosapi-radio:";
93 private static final String RADIO_MP3_URI = "x-rincon-mp3radio:";
94 private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
95 private static final String FILE_URI = "x-file-cifs:";
96 private static final String SPDIF = ":spdif";
97 private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
99 private static final String STATE_PLAYING = "PLAYING";
100 private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
101 private static final String STATE_STOPPED = "STOPPED";
103 private static final String LINEINCONNECTED = "LineInConnected";
104 private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
106 private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList("DeviceProperties", "AVTransport",
107 "ZoneGroupTopology", "GroupManagement", "RenderingControl", "AudioIn", "HTControl", "ContentDirectory");
108 protected static final int SUBSCRIPTION_DURATION = 1800;
110 private static final int SOCKET_TIMEOUT = 5000;
112 private static final int TUNEIN_DEFAULT_SERVICE_TYPE = 65031;
114 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
116 private final ThingRegistry localThingRegistry;
117 private final UpnpIOService service;
118 private final @Nullable String opmlUrl;
119 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
121 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
124 * Intrinsic lock used to synchronize the execution of notification sounds
126 private final Object notificationLock = new Object();
127 private final Object upnpLock = new Object();
128 private final Object stateLock = new Object();
129 private final Object jobLock = new Object();
131 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
133 private @Nullable ScheduledFuture<?> pollingJob;
134 private @Nullable SonosZonePlayerState savedState;
136 private Map<String, Boolean> subscriptionState = new HashMap<>();
139 * Thing handler instance of the coordinator speaker used for control delegation
141 private @Nullable ZonePlayerHandler coordinatorHandler;
143 private @Nullable List<SonosMusicService> musicServices;
145 private enum LineInType {
151 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
152 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
154 this.localThingRegistry = thingRegistry;
155 this.opmlUrl = opmlUrl;
156 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
157 this.service = upnpIOService;
158 this.stateDescriptionProvider = stateDescriptionProvider;
162 public void dispose() {
163 logger.debug("Handler disposed for thing {}", getThing().getUID());
165 ScheduledFuture<?> job = this.pollingJob;
169 this.pollingJob = null;
171 removeSubscription();
172 service.unregisterParticipant(this);
176 public void initialize() {
177 logger.debug("initializing handler for thing {}", getThing().getUID());
179 if (migrateThingType()) {
180 // we change the type, so we might need a different handler -> let's finish
184 configuration = getConfigAs(ZonePlayerConfiguration.class);
185 String udn = configuration.udn;
186 if (udn != null && !udn.isEmpty()) {
187 service.registerParticipant(this);
188 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
190 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
191 "@text/offline.conf-error-missing-udn");
192 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
196 private void poll() {
197 synchronized (jobLock) {
198 if (pollingJob == null) {
202 logger.debug("Polling job");
204 // First check if the Sonos zone is set in the UPnP service registry
205 // If not, set the thing state to OFFLINE and wait for the next poll
206 if (!isUpnpDeviceRegistered()) {
207 logger.debug("UPnP device {} not yet registered", getUDN());
208 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
209 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
210 synchronized (upnpLock) {
211 subscriptionState = new HashMap<>();
216 // Check if the Sonos zone can be joined
217 // If not, set the thing state to OFFLINE and do nothing else
219 if (getThing().getStatus() != ThingStatus.ONLINE) {
225 if (isLinked(ZONENAME)) {
226 updateCurrentZoneName();
231 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
232 if (isLinked(SLEEPTIMER) && isCoordinator()) {
233 updateSleepTimerDuration();
235 } catch (Exception e) {
236 logger.debug("Exception during poll: {}", e.getMessage(), e);
242 public void handleCommand(ChannelUID channelUID, Command command) {
243 if (command == RefreshType.REFRESH) {
244 updateChannel(channelUID.getId());
246 switch (channelUID.getId()) {
253 case NOTIFICATIONSOUND:
254 scheduleNotificationSound(command);
257 stopPlaying(command);
260 setVolumeForGroup(command);
266 removeMember(command);
269 becomeStandAlonePlayer();
272 publicAddress(LineInType.ANY);
274 case PUBLICANALOGADDRESS:
275 publicAddress(LineInType.ANALOG);
277 case PUBLICDIGITALADDRESS:
278 publicAddress(LineInType.DIGITAL);
283 case TUNEINSTATIONID:
284 playTuneinStation(command);
287 playFavorite(command);
293 snoozeAlarm(command);
296 saveAllPlayerState();
299 restoreAllPlayerState();
308 playPlayList(command);
327 if (command instanceof PlayPauseType) {
328 if (command == PlayPauseType.PLAY) {
329 getCoordinatorHandler().play();
330 } else if (command == PlayPauseType.PAUSE) {
331 getCoordinatorHandler().pause();
334 if (command instanceof NextPreviousType) {
335 if (command == NextPreviousType.NEXT) {
336 getCoordinatorHandler().next();
337 } else if (command == NextPreviousType.PREVIOUS) {
338 getCoordinatorHandler().previous();
341 // Rewind and Fast Forward are currently not implemented by the binding
342 } catch (IllegalStateException e) {
343 logger.debug("Cannot handle control command ({})", e.getMessage());
347 setSleepTimer(command);
356 setNightMode(command);
358 case SPEECHENHANCEMENT:
359 setSpeechEnhancement(command);
367 private void restoreAllPlayerState() {
368 for (Thing aThing : localThingRegistry.getAll()) {
369 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
370 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
371 if (handler != null) {
372 handler.restoreState();
378 private void saveAllPlayerState() {
379 for (Thing aThing : localThingRegistry.getAll()) {
380 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
381 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
382 if (handler != null) {
390 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
391 if (variable == null || value == null || service == null) {
395 if (getThing().getStatus() == ThingStatus.ONLINE) {
396 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
397 new Object[] { variable, value, service, this.getThing().getUID() });
399 String oldValue = this.stateMap.get(variable);
400 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
404 this.stateMap.put(variable, value);
406 // pre-process some variables, eg XML processing
407 if (service.equals("AVTransport") && variable.equals("LastChange")) {
408 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
409 for (String parsedValue : parsedValues.keySet()) {
410 // Update the transport state after the update of the media information
411 // to not break the notification mechanism
412 if (!parsedValue.equals("TransportState")) {
413 onValueReceived(parsedValue, parsedValues.get(parsedValue), "AVTransport");
415 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
416 // for a compatibility with the result of the action GetMediaInfo
417 if (parsedValue.equals("AVTransportURI")) {
418 onValueReceived("CurrentURI", parsedValues.get(parsedValue), service);
419 } else if (parsedValue.equals("AVTransportURIMetaData")) {
420 onValueReceived("CurrentURIMetaData", parsedValues.get(parsedValue), service);
423 updateMediaInformation();
424 if (parsedValues.get("TransportState") != null) {
425 onValueReceived("TransportState", parsedValues.get("TransportState"), "AVTransport");
429 if (service.equals("RenderingControl") && variable.equals("LastChange")) {
430 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
431 for (String parsedValue : parsedValues.keySet()) {
432 onValueReceived(parsedValue, parsedValues.get(parsedValue), "RenderingControl");
436 List<StateOption> options = new ArrayList<>();
438 // update the appropriate channel
440 case "TransportState":
441 updateChannel(STATE);
442 updateChannel(CONTROL);
444 dispatchOnAllGroupMembers(variable, value, service);
446 case "CurrentPlayMode":
447 updateChannel(SHUFFLE);
448 updateChannel(REPEAT);
449 dispatchOnAllGroupMembers(variable, value, service);
451 case "CurrentLEDState":
455 updateState(ZONENAME, new StringType(value));
457 case "CurrentZoneName":
458 updateChannel(ZONENAME);
460 case "ZoneGroupState":
461 updateChannel(COORDINATOR);
462 // Update coordinator after a change is made to the grouping of Sonos players
463 updateGroupCoordinator();
464 updateMediaInformation();
465 // Update state and control channels for the group members with the coordinator values
466 String transportState = getTransportState();
467 if (transportState != null) {
468 dispatchOnAllGroupMembers("TransportState", transportState, "AVTransport");
470 // Update shuffle and repeat channels for the group members with the coordinator values
471 String playMode = getPlayMode();
472 if (playMode != null) {
473 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, "AVTransport");
476 case "LocalGroupUUID":
477 updateChannel(ZONEGROUPID);
479 case "GroupCoordinatorIsLocal":
480 updateChannel(LOCALCOORDINATOR);
483 updateChannel(VOLUME);
489 updateChannel(NIGHTMODE);
492 updateChannel(SPEECHENHANCEMENT);
494 case LINEINCONNECTED:
495 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
496 updateChannel(LINEIN);
498 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
499 .contains(getThing().getThingTypeUID())) {
500 updateChannel(ANALOGLINEIN);
503 case TOSLINEINCONNECTED:
504 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
505 updateChannel(LINEIN);
507 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
508 .contains(getThing().getThingTypeUID())) {
509 updateChannel(DIGITALLINEIN);
513 updateChannel(ALARMRUNNING);
514 updateRunningAlarmProperties();
516 case "RunningAlarmProperties":
517 updateChannel(ALARMPROPERTIES);
519 case "CurrentURIFormatted":
520 updateChannel(CURRENTTRACK);
523 updateChannel(CURRENTTITLE);
525 case "CurrentArtist":
526 updateChannel(CURRENTARTIST);
529 updateChannel(CURRENTALBUM);
532 updateChannel(CURRENTTRANSPORTURI);
534 case "CurrentTrackURI":
535 updateChannel(CURRENTTRACKURI);
537 case "CurrentAlbumArtURI":
538 updateChannel(CURRENTALBUMARTURL);
540 case "CurrentSleepTimerGeneration":
541 if (value.equals("0")) {
542 updateState(SLEEPTIMER, new DecimalType(0));
545 case "SleepTimerGeneration":
546 if (value.equals("0")) {
547 updateState(SLEEPTIMER, new DecimalType(0));
549 updateSleepTimerDuration();
552 case "RemainingSleepTimerDuration":
553 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
555 case "CurrentTuneInStationId":
556 updateChannel(TUNEINSTATIONID);
558 case "SavedQueuesUpdateID": // service ContentDirectoy
559 for (SonosEntry entry : getPlayLists()) {
560 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
562 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
564 case "FavoritesUpdateID": // service ContentDirectoy
565 for (SonosEntry entry : getFavorites()) {
566 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
568 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
570 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
571 // Due to a bug in the data type definition of this state variable, it is not set.
572 // As a workaround, we check the state variable named ContainerUpdateIDs.
573 case "ContainerUpdateIDs": // service ContentDirectoy
574 if (value.startsWith("R:0,") || stateDescriptionProvider
575 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
576 for (SonosEntry entry : getFavoriteRadios()) {
577 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
579 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
588 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
589 if (isCoordinator()) {
590 for (String member : getOtherZoneGroupMembers()) {
592 ZonePlayerHandler memberHandler = getHandlerByName(member);
593 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
594 memberHandler.onValueReceived(variable, value, service);
596 } catch (IllegalStateException e) {
597 logger.debug("Cannot update channel for group member ({})", e.getMessage());
603 private @Nullable String getAlbumArtUrl() {
605 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
606 if (albumArtURI != null) {
607 if (albumArtURI.startsWith("http")) {
609 } else if (albumArtURI.startsWith("/")) {
611 URL serviceDescrUrl = service.getDescriptorURL(this);
612 if (serviceDescrUrl != null) {
613 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
614 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
616 } catch (MalformedURLException e) {
617 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
624 protected void updateChannel(String channelId) {
625 if (!isLinked(channelId)) {
631 State newState = UnDefType.UNDEF;
635 value = getTransportState();
637 newState = new StringType(value);
641 value = getTransportState();
642 if (STATE_PLAYING.equals(value)) {
643 newState = PlayPauseType.PLAY;
644 } else if (STATE_STOPPED.equals(value)) {
645 newState = PlayPauseType.PAUSE;
646 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
647 newState = PlayPauseType.PAUSE;
651 value = getTransportState();
653 newState = STATE_STOPPED.equals(value) ? OnOffType.ON : OnOffType.OFF;
657 if (getPlayMode() != null) {
658 newState = isShuffleActive() ? OnOffType.ON : OnOffType.OFF;
662 if (getPlayMode() != null) {
663 newState = new StringType(getRepeatMode());
667 if (getLed() != null) {
668 newState = isLedOn() ? OnOffType.ON : OnOffType.OFF;
672 value = getCurrentZoneName();
674 newState = new StringType(value);
678 value = getZoneGroupID();
680 newState = new StringType(value);
684 newState = new StringType(getCoordinator());
686 case LOCALCOORDINATOR:
687 if (getGroupCoordinatorIsLocal() != null) {
688 newState = isGroupCoordinator() ? OnOffType.ON : OnOffType.OFF;
694 newState = new PercentType(value);
700 newState = isMuted() ? OnOffType.ON : OnOffType.OFF;
704 value = getNightMode();
706 newState = isNightModeOn() ? OnOffType.ON : OnOffType.OFF;
709 case SPEECHENHANCEMENT:
710 value = getDialogLevel();
712 newState = isSpeechEnhanced() ? OnOffType.ON : OnOffType.OFF;
716 if (getAnalogLineInConnected() != null) {
717 newState = isAnalogLineInConnected() ? OnOffType.ON : OnOffType.OFF;
718 } else if (getOpticalLineInConnected() != null) {
719 newState = isOpticalLineInConnected() ? OnOffType.ON : OnOffType.OFF;
723 if (getAnalogLineInConnected() != null) {
724 newState = isAnalogLineInConnected() ? OnOffType.ON : OnOffType.OFF;
728 if (getOpticalLineInConnected() != null) {
729 newState = isOpticalLineInConnected() ? OnOffType.ON : OnOffType.OFF;
733 if (getAlarmRunning() != null) {
734 newState = isAlarmRunning() ? OnOffType.ON : OnOffType.OFF;
737 case ALARMPROPERTIES:
738 value = getRunningAlarmProperties();
740 newState = new StringType(value);
744 value = stateMap.get("CurrentURIFormatted");
746 newState = new StringType(value);
750 value = getCurrentTitle();
752 newState = new StringType(value);
756 value = getCurrentArtist();
758 newState = new StringType(value);
762 value = getCurrentAlbum();
764 newState = new StringType(value);
767 case CURRENTALBUMART:
769 updateAlbumArtChannel(false);
771 case CURRENTALBUMARTURL:
772 url = getAlbumArtUrl();
774 newState = new StringType(url);
777 case CURRENTTRANSPORTURI:
778 value = getCurrentURI();
780 newState = new StringType(value);
783 case CURRENTTRACKURI:
784 value = stateMap.get("CurrentTrackURI");
786 newState = new StringType(value);
789 case TUNEINSTATIONID:
790 value = stateMap.get("CurrentTuneInStationId");
792 newState = new StringType(value);
799 if (newState != null) {
800 updateState(channelId, newState);
804 private void updateAlbumArtChannel(boolean allGroup) {
805 String url = getAlbumArtUrl();
807 // We download the cover art in a different thread to not delay the other operations
808 scheduler.submit(() -> {
809 RawType image = HttpUtil.downloadImage(url, true, 500000);
810 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
813 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
817 private void updateChannel(String channeldD, State state, boolean allGroup) {
819 for (String member : getZoneGroupMembers()) {
821 ZonePlayerHandler memberHandler = getHandlerByName(member);
822 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
823 && memberHandler.isLinked(channeldD)) {
824 memberHandler.updateState(channeldD, state);
826 } catch (IllegalStateException e) {
827 logger.debug("Cannot update channel for group member ({})", e.getMessage());
830 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
831 updateState(channeldD, state);
836 * CurrentURI will not change, but will trigger change of CurrentURIFormated
837 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
839 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
840 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
843 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
844 return oldValue != null ? !oldValue.equals(value) : value != null;
848 * Similar to the AVTransport eventing, the Queue events its state variables
849 * as sub values within a synthesized LastChange state variable.
851 private boolean isQueueEvent(String variable) {
852 return "LastChange".equals(variable);
855 private void updateGroupCoordinator() {
857 coordinatorHandler = getHandlerByName(getCoordinator());
858 } catch (IllegalStateException e) {
859 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
860 coordinatorHandler = null;
864 private boolean isUpnpDeviceRegistered() {
865 return service.isRegistered(this);
868 private void addSubscription() {
869 synchronized (upnpLock) {
870 // Set up GENA Subscriptions
871 if (service.isRegistered(this)) {
872 for (String subscription : SERVICE_SUBSCRIPTIONS) {
873 Boolean state = subscriptionState.get(subscription);
874 if (state == null || !state) {
875 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
876 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
877 subscriptionState.put(subscription, true);
884 private void removeSubscription() {
885 synchronized (upnpLock) {
886 // Set up GENA Subscriptions
887 if (service.isRegistered(this)) {
888 for (String subscription : SERVICE_SUBSCRIPTIONS) {
889 Boolean state = subscriptionState.get(subscription);
890 if (state != null && state) {
891 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
892 service.removeSubscription(this, subscription);
896 subscriptionState = new HashMap<>();
901 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
902 if (service == null) {
905 synchronized (upnpLock) {
906 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
907 subscriptionState.put(service, succeeded);
911 private void updatePlayerState() {
912 if (!updateZoneInfo()) {
913 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
914 logger.debug("Sonos player {} is not available in local network", getUDN());
915 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
916 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
917 synchronized (upnpLock) {
918 subscriptionState = new HashMap<>();
921 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
922 logger.debug("Sonos player {} has been found in local network", getUDN());
923 updateStatus(ThingStatus.ONLINE);
927 protected void updateCurrentZoneName() {
928 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneAttributes", null);
930 for (String variable : result.keySet()) {
931 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
935 protected void updateLed() {
936 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
938 for (String variable : result.keySet()) {
939 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
943 protected void updateTime() {
944 Map<String, String> result = service.invokeAction(this, "AlarmClock", "GetTimeNow", null);
946 for (String variable : result.keySet()) {
947 this.onValueReceived(variable, result.get(variable), "AlarmClock");
951 protected void updatePosition() {
952 Map<String, String> result = service.invokeAction(this, "AVTransport", "GetPositionInfo", null);
954 for (String variable : result.keySet()) {
955 this.onValueReceived(variable, result.get(variable), "AVTransport");
959 protected void updateRunningAlarmProperties() {
960 Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRunningAlarmProperties", null);
962 String alarmID = result.get("AlarmID");
963 String loggedStartTime = result.get("LoggedStartTime");
964 String newStringValue = null;
965 if (alarmID != null && loggedStartTime != null) {
966 newStringValue = alarmID + " - " + loggedStartTime;
968 newStringValue = "No running alarm";
970 result.put("RunningAlarmProperties", newStringValue);
972 for (String variable : result.keySet()) {
973 this.onValueReceived(variable, result.get(variable), "AVTransport");
977 protected boolean updateZoneInfo() {
978 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneInfo", null);
979 for (String variable : result.keySet()) {
980 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
983 Map<String, String> properties = editProperties();
984 String value = stateMap.get("HardwareVersion");
985 if (value != null && !value.isEmpty()) {
986 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
988 value = stateMap.get("DisplaySoftwareVersion");
989 if (value != null && !value.isEmpty()) {
990 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
992 value = stateMap.get("SerialNumber");
993 if (value != null && !value.isEmpty()) {
994 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
996 value = stateMap.get("MACAddress");
997 if (value != null && !value.isEmpty()) {
998 properties.put(MAC_ADDRESS, value);
1000 value = stateMap.get("IPAddress");
1001 if (value != null && !value.isEmpty()) {
1002 properties.put(IP_ADDRESS, value);
1004 updateProperties(properties);
1006 return !result.isEmpty();
1009 public String getCoordinator() {
1010 for (SonosZoneGroup zg : getZoneGroups()) {
1011 if (zg.getMembers().contains(getUDN())) {
1012 return zg.getCoordinator();
1018 public boolean isCoordinator() {
1019 return getUDN().equals(getCoordinator());
1022 protected void updateMediaInformation() {
1023 String currentURI = getCurrentURI();
1024 SonosMetaData currentTrack = getTrackMetadata();
1025 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1027 String artist = null;
1028 String album = null;
1029 String title = null;
1030 String resultString = null;
1031 String stationID = null;
1032 boolean needsUpdating = false;
1034 // if currentURI == null, we do nothing
1035 if (currentURI != null) {
1036 if (currentURI.isEmpty()) {
1038 needsUpdating = true;
1041 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1042 // The Sonos is a slave member of a group
1043 // The media information will be updated by the coordinator
1044 // Notification of group change occurs later, so we just check the URI
1046 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1047 // Radio stream (tune-in)
1048 boolean opmlUrlSucceeded = false;
1049 stationID = extractStationId(currentURI);
1050 String url = opmlUrl;
1052 String mac = getMACAddress();
1053 if (stationID != null && !stationID.isEmpty() && mac != null && !mac.isEmpty()) {
1054 url = url.replace("%id", stationID);
1055 url = url.replace("%serial", mac);
1057 String response = null;
1059 response = HttpUtil.executeUrl("GET", url, SOCKET_TIMEOUT);
1060 } catch (IOException e) {
1061 logger.debug("Request to device failed", e);
1064 if (response != null) {
1065 List<String> fields = SonosXMLParser.getRadioTimeFromXML(response);
1067 if (!fields.isEmpty()) {
1068 opmlUrlSucceeded = true;
1071 for (String field : fields) {
1072 if (resultString.isEmpty()) {
1073 // radio name should be first field
1076 resultString += " - ";
1078 resultString += field;
1081 needsUpdating = true;
1086 if (!opmlUrlSucceeded) {
1087 if (currentUriMetaData != null) {
1088 title = currentUriMetaData.getTitle();
1089 if (currentTrack == null || currentTrack.getStreamContent().isEmpty()) {
1090 resultString = title;
1092 resultString = title + " - " + currentTrack.getStreamContent();
1094 needsUpdating = true;
1099 else if (isPlayingLineIn(currentURI)) {
1100 if (currentTrack != null) {
1101 title = currentTrack.getTitle();
1102 resultString = title;
1103 needsUpdating = true;
1107 else if (isPlayingRadio(currentURI)
1108 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1109 // isPlayingRadio(currentURI) is true for Google Play Music radio or Apple Music radio
1110 if (currentTrack != null) {
1111 artist = !currentTrack.getAlbumArtist().isEmpty() ? currentTrack.getAlbumArtist()
1112 : currentTrack.getCreator();
1113 album = currentTrack.getAlbum();
1114 title = currentTrack.getTitle();
1115 resultString = artist + " - " + album + " - " + title;
1116 needsUpdating = true;
1121 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1122 ? currentTrack.getAlbumArtUri()
1125 ZonePlayerHandler handlerForImageUpdate = null;
1126 for (String member : getZoneGroupMembers()) {
1128 ZonePlayerHandler memberHandler = getHandlerByName(member);
1129 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1130 if (memberHandler.isLinked(CURRENTALBUMART)
1131 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1132 handlerForImageUpdate = memberHandler;
1134 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1136 if (needsUpdating) {
1137 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "", "AVTransport");
1138 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "", "AVTransport");
1139 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "", "AVTransport");
1140 memberHandler.onValueReceived("CurrentURIFormatted", (resultString != null) ? resultString : "",
1142 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, "AVTransport");
1145 } catch (IllegalStateException e) {
1146 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1149 if (needsUpdating && handlerForImageUpdate != null) {
1150 handlerForImageUpdate.updateAlbumArtChannel(true);
1154 private @Nullable String extractStationId(String uri) {
1155 String stationID = null;
1156 if (isPlayingStream(uri)) {
1157 stationID = substringBetween(uri, ":s", "?sid");
1158 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1159 stationID = substringBetween(uri, "sid=s", "&");
1164 private @Nullable String substringBetween(String str, String open, String close) {
1165 String result = null;
1166 int idx1 = str.indexOf(open);
1168 idx1 += open.length();
1169 int idx2 = str.indexOf(close, idx1);
1171 result = str.substring(idx1, idx2);
1177 public @Nullable String getGroupCoordinatorIsLocal() {
1178 return stateMap.get("GroupCoordinatorIsLocal");
1181 public boolean isGroupCoordinator() {
1182 return "true".equals(getGroupCoordinatorIsLocal());
1186 public String getUDN() {
1187 String udn = configuration.udn;
1188 return udn != null && !udn.isEmpty() ? udn : "undefined";
1191 public @Nullable String getCurrentURI() {
1192 return stateMap.get("CurrentURI");
1195 public @Nullable String getCurrentURIMetadataAsString() {
1196 return stateMap.get("CurrentURIMetaData");
1199 public @Nullable SonosMetaData getCurrentURIMetadata() {
1200 String metaData = getCurrentURIMetadataAsString();
1201 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1204 public @Nullable SonosMetaData getTrackMetadata() {
1205 String metaData = stateMap.get("CurrentTrackMetaData");
1206 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1209 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1210 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1211 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1214 public @Nullable String getMACAddress() {
1215 String mac = stateMap.get("MACAddress");
1216 if (mac == null || mac.isEmpty()) {
1219 return stateMap.get("MACAddress");
1222 public @Nullable String getRefreshedPosition() {
1224 return stateMap.get("RelTime");
1227 public long getRefreshedCurrenTrackNr() {
1229 String value = stateMap.get("Track");
1230 if (value != null) {
1231 return Long.valueOf(value);
1237 public @Nullable String getVolume() {
1238 return stateMap.get("VolumeMaster");
1241 public @Nullable String getTransportState() {
1242 return stateMap.get("TransportState");
1245 public @Nullable String getCurrentTitle() {
1246 return stateMap.get("CurrentTitle");
1249 public @Nullable String getCurrentArtist() {
1250 return stateMap.get("CurrentArtist");
1253 public @Nullable String getCurrentAlbum() {
1254 return stateMap.get("CurrentAlbum");
1257 public List<SonosEntry> getArtists(String filter) {
1258 return getEntries("A:", filter);
1261 public List<SonosEntry> getArtists() {
1262 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1265 public List<SonosEntry> getAlbums(String filter) {
1266 return getEntries("A:ALBUM", filter);
1269 public List<SonosEntry> getAlbums() {
1270 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1273 public List<SonosEntry> getTracks(String filter) {
1274 return getEntries("A:TRACKS", filter);
1277 public List<SonosEntry> getTracks() {
1278 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1281 public List<SonosEntry> getQueue(String filter) {
1282 return getEntries("Q:0", filter);
1285 public List<SonosEntry> getQueue() {
1286 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1289 public long getQueueSize() {
1290 return getNbEntries("Q:0");
1293 public List<SonosEntry> getPlayLists(String filter) {
1294 return getEntries("SQ:", filter);
1297 public List<SonosEntry> getPlayLists() {
1298 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1301 public List<SonosEntry> getFavoriteRadios(String filter) {
1302 return getEntries("R:0/0", filter);
1305 public List<SonosEntry> getFavoriteRadios() {
1306 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1310 * Searches for entries in the 'favorites' list on a sonos account
1314 public List<SonosEntry> getFavorites() {
1315 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1318 protected List<SonosEntry> getEntries(String type, String filter) {
1321 Map<String, String> inputs = new HashMap<>();
1322 inputs.put("ObjectID", type);
1323 inputs.put("BrowseFlag", "BrowseDirectChildren");
1324 inputs.put("Filter", filter);
1325 inputs.put("StartingIndex", Long.toString(startAt));
1326 inputs.put("RequestedCount", Integer.toString(200));
1327 inputs.put("SortCriteria", "");
1329 Map<String, String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1331 String initialResult = result.get("Result");
1332 if (initialResult == null) {
1333 return Collections.emptyList();
1336 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1337 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1339 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1340 startAt = startAt + initialNumberReturned;
1342 while (startAt < totalMatches) {
1343 inputs.put("StartingIndex", Long.toString(startAt));
1344 result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1346 // Execute this action synchronously
1347 String nextResult = result.get("Result");
1348 if (nextResult == null) {
1352 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1354 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1356 startAt = startAt + numberReturned;
1362 protected long getNbEntries(String type) {
1363 Map<String, String> inputs = new HashMap<>();
1364 inputs.put("ObjectID", type);
1365 inputs.put("BrowseFlag", "BrowseDirectChildren");
1366 inputs.put("Filter", "dc:title");
1367 inputs.put("StartingIndex", "0");
1368 inputs.put("RequestedCount", "1");
1369 inputs.put("SortCriteria", "");
1371 Map<String, String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1373 return getResultEntry(result, "TotalMatches", type, "dc:title");
1377 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1379 * @param resultInput - the map to be examined for the requestedKey
1380 * @param requestedKey - the key to be sought in the resultInput map
1381 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1382 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1384 * @return 0 as long or the value corresponding to the requiredKey if found
1386 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1387 String entriesFilter) {
1390 if (resultInput.isEmpty()) {
1395 String resultString = resultInput.get(requestedKey);
1396 if (resultString == null) {
1397 throw new NumberFormatException("Requested key is null.");
1399 result = Long.valueOf(resultString);
1400 } catch (NumberFormatException ex) {
1401 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1402 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1409 * Save the state (track, position etc) of the Sonos Zone player.
1411 * @return true if no error occurred.
1413 protected void saveState() {
1414 synchronized (stateLock) {
1415 savedState = new SonosZonePlayerState();
1416 String currentURI = getCurrentURI();
1418 savedState.transportState = getTransportState();
1419 savedState.volume = getVolume();
1421 if (currentURI != null) {
1422 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
1423 || isPlayingRadio(currentURI)) {
1424 // we are streaming music, like tune-in radio or Google Play Music radio
1425 SonosMetaData track = getTrackMetadata();
1426 SonosMetaData current = getCurrentURIMetadata();
1427 if (track != null && current != null) {
1428 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1429 current.getUpnpClass(), currentURI);
1431 } else if (currentURI.contains(GROUP_URI)) {
1432 // we are a slave to some coordinator
1433 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1434 } else if (isPlayingLineIn(currentURI)) {
1435 // we are streaming from the Line In connection
1436 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1437 } else if (isPlayingQueue(currentURI)) {
1438 // we are playing something that sits in the queue
1439 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1440 if (queued != null) {
1441 savedState.track = getRefreshedCurrenTrackNr();
1443 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1444 // we are playing a real 'saved' playlist
1445 List<SonosEntry> playLists = getPlayLists();
1446 for (SonosEntry someList : playLists) {
1447 if (someList.getTitle().equals(queued.getTitle())) {
1448 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1449 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1454 } else if (queued.getUpnpClass().contains("object.container")) {
1455 // we are playing some other sort of
1456 // 'container' - we will save that to a
1457 // playlist for our convenience
1458 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1460 // save the playlist
1461 String existingList = "";
1462 List<SonosEntry> playLists = getPlayLists();
1463 for (SonosEntry someList : playLists) {
1464 if (someList.getTitle().equals(ESH_PREFIX + getUDN())) {
1465 existingList = someList.getId();
1470 saveQueue(ESH_PREFIX + getUDN(), existingList);
1472 // get all the playlists and a ref to our
1474 playLists = getPlayLists();
1475 for (SonosEntry someList : playLists) {
1476 if (someList.getTitle().equals(ESH_PREFIX + getUDN())) {
1477 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1478 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1485 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1489 savedState.relTime = getRefreshedPosition();
1491 savedState.entry = null;
1497 * Restore the state (track, position etc) of the Sonos Zone player.
1499 * @return true if no error occurred.
1501 protected void restoreState() {
1502 synchronized (stateLock) {
1503 SonosZonePlayerState state = savedState;
1504 if (state != null) {
1505 // put settings back
1506 String volume = state.volume;
1507 if (volume != null) {
1508 setVolume(DecimalType.valueOf(volume));
1511 if (isCoordinator()) {
1512 SonosEntry entry = state.entry;
1513 if (entry != null) {
1514 // check if we have a playlist to deal with
1515 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1516 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1517 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1518 setCurrentURI(entry);
1519 setPositionTrack(state.track);
1521 setCurrentURI(entry);
1522 setPosition(state.relTime);
1526 String transportState = state.transportState;
1527 if (transportState != null) {
1528 if (transportState.equals(STATE_PLAYING)) {
1530 } else if (transportState.equals(STATE_STOPPED)) {
1532 } else if (transportState.equals(STATE_PAUSED_PLAYBACK)) {
1541 public void saveQueue(String name, String queueID) {
1542 Map<String, String> inputs = new HashMap<>();
1543 inputs.put("Title", name);
1544 inputs.put("ObjectID", queueID);
1546 Map<String, String> result = service.invokeAction(this, "AVTransport", "SaveQueue", inputs);
1548 for (String variable : result.keySet()) {
1549 this.onValueReceived(variable, result.get(variable), "AVTransport");
1553 public void setVolume(Command command) {
1554 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1555 || command instanceof PercentType) {
1556 Map<String, String> inputs = new HashMap<>();
1558 String newValue = null;
1559 String currentVolume = getVolume();
1560 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1561 int i = Integer.valueOf(currentVolume);
1562 newValue = String.valueOf(Math.min(100, i + 1));
1563 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1564 int i = Integer.valueOf(currentVolume);
1565 newValue = String.valueOf(Math.max(0, i - 1));
1566 } else if (command == OnOffType.ON) {
1568 } else if (command == OnOffType.OFF) {
1570 } else if (command instanceof DecimalType) {
1571 newValue = String.valueOf(((DecimalType) command).intValue());
1575 inputs.put("Channel", "Master");
1576 inputs.put("DesiredVolume", newValue);
1578 Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetVolume", inputs);
1580 for (String variable : result.keySet()) {
1581 this.onValueReceived(variable, result.get(variable), "RenderingControl");
1587 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1588 * AdHoc groups handles the volume specifically for each player.
1589 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1591 public void setVolumeForGroup(Command command) {
1592 if (isAdHocGroup() || isStandalonePlayer()) {
1596 getCoordinatorHandler().setVolume(command);
1597 } catch (IllegalStateException e) {
1598 logger.debug("Cannot set group volume ({})", e.getMessage());
1604 * Checks if the player receiving the command is part of a group that
1605 * consists of randomly added players or contains bonded players
1609 private boolean isAdHocGroup() {
1610 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1611 if (currentZoneGroup != null) {
1612 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1614 for (String zoneName : zoneGroupMemberNames) {
1615 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1616 // At least one "ZoneName" differs so we have an AdHoc group
1625 * Checks if the player receiving the command is a standalone player
1629 private boolean isStandalonePlayer() {
1630 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1631 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1634 private Collection<SonosZoneGroup> getZoneGroups() {
1635 String zoneGroupState = stateMap.get("ZoneGroupState");
1636 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1640 * Returns the current zone group
1641 * (of which the player receiving the command is part)
1643 * @return {@link SonosZoneGroup}
1645 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1646 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1647 if (zoneGroup.getMembers().contains(getUDN())) {
1651 logger.debug("Could not fetch Sonos group state information");
1656 * Sets the volume level for a notification sound
1658 * @param notificationSoundVolume
1660 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1661 if (notificationSoundVolume != null) {
1662 setVolumeForGroup(notificationSoundVolume);
1667 * Gets the volume level for a notification sound
1669 public @Nullable PercentType getNotificationSoundVolume() {
1670 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1671 if (notificationSoundVolume == null) {
1672 // if no value is set we use the current volume instead
1673 String volume = getVolume();
1674 return volume != null ? new PercentType(volume) : null;
1676 return new PercentType(notificationSoundVolume);
1679 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1680 Map<String, String> inputs = new HashMap<>();
1683 inputs.put("InstanceID", "0");
1684 inputs.put("EnqueuedURI", URI);
1685 inputs.put("EnqueuedURIMetaData", meta);
1686 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1687 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1688 } catch (NumberFormatException ex) {
1689 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1692 Map<String, String> result = service.invokeAction(this, "AVTransport", "AddURIToQueue", inputs);
1694 for (String variable : result.keySet()) {
1695 this.onValueReceived(variable, result.get(variable), "AVTransport");
1699 public void setCurrentURI(SonosEntry newEntry) {
1700 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1703 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1704 if (URI != null && URIMetaData != null) {
1705 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1706 Map<String, String> inputs = new HashMap<>();
1709 inputs.put("InstanceID", "0");
1710 inputs.put("CurrentURI", URI);
1711 inputs.put("CurrentURIMetaData", URIMetaData);
1712 } catch (NumberFormatException ex) {
1713 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1716 Map<String, String> result = service.invokeAction(this, "AVTransport", "SetAVTransportURI", inputs);
1718 for (String variable : result.keySet()) {
1719 this.onValueReceived(variable, result.get(variable), "AVTransport");
1724 public void setPosition(@Nullable String relTime) {
1725 seek("REL_TIME", relTime);
1728 public void setPositionTrack(long tracknr) {
1729 seek("TRACK_NR", Long.toString(tracknr));
1732 public void setPositionTrack(String tracknr) {
1733 seek("TRACK_NR", tracknr);
1736 protected void seek(String unit, @Nullable String target) {
1737 if (target != null) {
1738 Map<String, String> inputs = new HashMap<>();
1741 inputs.put("InstanceID", "0");
1742 inputs.put("Unit", unit);
1743 inputs.put("Target", target);
1744 } catch (NumberFormatException ex) {
1745 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1748 Map<String, String> result = service.invokeAction(this, "AVTransport", "Seek", inputs);
1750 for (String variable : result.keySet()) {
1751 this.onValueReceived(variable, result.get(variable), "AVTransport");
1756 public void play() {
1757 Map<String, String> inputs = new HashMap<>();
1758 inputs.put("Speed", "1");
1760 Map<String, String> result = service.invokeAction(this, "AVTransport", "Play", inputs);
1762 for (String variable : result.keySet()) {
1763 this.onValueReceived(variable, result.get(variable), "AVTransport");
1767 public void stop() {
1768 Map<String, String> result = service.invokeAction(this, "AVTransport", "Stop", null);
1770 for (String variable : result.keySet()) {
1771 this.onValueReceived(variable, result.get(variable), "AVTransport");
1775 public void pause() {
1776 Map<String, String> result = service.invokeAction(this, "AVTransport", "Pause", null);
1778 for (String variable : result.keySet()) {
1779 this.onValueReceived(variable, result.get(variable), "AVTransport");
1783 public void setShuffle(Command command) {
1784 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1786 ZonePlayerHandler coordinator = getCoordinatorHandler();
1788 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1789 || command.equals(OpenClosedType.OPEN)) {
1790 switch (coordinator.getRepeatMode()) {
1792 coordinator.updatePlayMode("SHUFFLE");
1795 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
1798 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
1801 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
1802 || command.equals(OpenClosedType.CLOSED)) {
1803 switch (coordinator.getRepeatMode()) {
1805 coordinator.updatePlayMode("REPEAT_ALL");
1808 coordinator.updatePlayMode("REPEAT_ONE");
1811 coordinator.updatePlayMode("NORMAL");
1815 } catch (IllegalStateException e) {
1816 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
1821 public void setRepeat(Command command) {
1822 if (command instanceof StringType) {
1824 ZonePlayerHandler coordinator = getCoordinatorHandler();
1826 switch (command.toString()) {
1828 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
1831 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
1834 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
1837 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
1838 command.toString());
1841 } catch (IllegalStateException e) {
1842 logger.debug("Cannot handle repeat command ({})", e.getMessage());
1847 public void setNightMode(Command command) {
1848 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1849 setEQ("NightMode", (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1850 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
1854 public void setSpeechEnhancement(Command command) {
1855 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1856 setEQ("DialogLevel", (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1857 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
1861 private void setEQ(String eqType, String value) {
1863 Map<String, String> inputs = new HashMap<>();
1864 inputs.put("InstanceID", "0");
1865 inputs.put("EQType", eqType);
1866 inputs.put("DesiredValue", value);
1867 Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetEQ", inputs);
1869 for (String variable : result.keySet()) {
1870 this.onValueReceived(variable, result.get(variable), "RenderingControl");
1872 } catch (IllegalStateException e) {
1873 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
1877 public @Nullable String getNightMode() {
1878 return stateMap.get("NightMode");
1881 public boolean isNightModeOn() {
1882 return "1".equals(getNightMode());
1885 public @Nullable String getDialogLevel() {
1886 return stateMap.get("DialogLevel");
1889 public boolean isSpeechEnhanced() {
1890 return "1".equals(getDialogLevel());
1893 public @Nullable String getPlayMode() {
1894 return stateMap.get("CurrentPlayMode");
1897 public Boolean isShuffleActive() {
1898 String playMode = getPlayMode();
1899 return (playMode != null && playMode.startsWith("SHUFFLE"));
1902 public String getRepeatMode() {
1903 String mode = "OFF";
1904 String playMode = getPlayMode();
1905 if (playMode != null) {
1912 case "SHUFFLE_REPEAT_ONE":
1916 case "SHUFFLE_NOREPEAT":
1925 protected void updatePlayMode(String playMode) {
1926 Map<String, String> inputs = new HashMap<>();
1927 inputs.put("InstanceID", "0");
1928 inputs.put("NewPlayMode", playMode);
1930 Map<String, String> result = service.invokeAction(this, "AVTransport", "SetPlayMode", inputs);
1932 for (String variable : result.keySet()) {
1933 this.onValueReceived(variable, result.get(variable), "AVTransport");
1938 * Clear all scheduled music from the current queue.
1941 public void removeAllTracksFromQueue() {
1942 Map<String, String> inputs = new HashMap<>();
1943 inputs.put("InstanceID", "0");
1945 Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveAllTracksFromQueue", inputs);
1947 for (String variable : result.keySet()) {
1948 this.onValueReceived(variable, result.get(variable), "AVTransport");
1953 * Play music from the line-in of the given Player referenced by the given UDN or name
1955 * @param udn or name
1957 public void playLineIn(Command command) {
1958 if (command instanceof StringType) {
1960 LineInType lineInType = LineInType.ANY;
1961 String remotePlayerName = command.toString();
1962 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
1963 lineInType = LineInType.ANALOG;
1964 remotePlayerName = remotePlayerName.substring(7);
1965 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
1966 lineInType = LineInType.DIGITAL;
1967 remotePlayerName = remotePlayerName.substring(8);
1969 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
1970 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
1972 // check if player has a line-in connected
1973 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
1974 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
1975 // stop whatever is currently playing
1976 coordinatorHandler.stop();
1979 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
1980 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
1982 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
1985 // take the system off mute
1986 coordinatorHandler.setMute(OnOffType.OFF);
1989 coordinatorHandler.play();
1991 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
1993 } catch (IllegalStateException e) {
1994 logger.debug("Cannot play line-in ({})", e.getMessage());
1999 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2000 ZonePlayerHandler handler = coordinatorHandler;
2001 if (handler != null) {
2005 handler = getHandlerByName(getCoordinator());
2006 coordinatorHandler = handler;
2008 } catch (IllegalStateException e) {
2009 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2014 * Returns a list of all zone group members this particular player is member of
2015 * Or empty list if the players is not assigned to any group
2017 * @return a list of Strings containing the UDNs of other group members
2019 protected List<String> getZoneGroupMembers() {
2020 List<String> result = new ArrayList<>();
2022 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2023 if (!zoneGroups.isEmpty()) {
2024 for (SonosZoneGroup zg : zoneGroups) {
2025 if (zg.getMembers().contains(getUDN())) {
2026 result.addAll(zg.getMembers());
2031 // If the group topology was not yet received, return at least the current Sonos zone
2032 result.add(getUDN());
2038 * Returns a list of other zone group members this particular player is member of
2039 * Or empty list if the players is not assigned to any group
2041 * @return a list of Strings containing the UDNs of other group members
2043 protected List<String> getOtherZoneGroupMembers() {
2044 List<String> zoneGroupMembers = getZoneGroupMembers();
2045 zoneGroupMembers.remove(getUDN());
2046 return zoneGroupMembers;
2049 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2050 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2051 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2052 if (thing != null) {
2053 ThingHandler handler = thing.getHandler();
2054 if (handler instanceof ZonePlayerHandler) {
2055 return (ZonePlayerHandler) handler;
2059 for (Thing aThing : localThingRegistry.getAll()) {
2060 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2061 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2062 ThingHandler handler = aThing.getHandler();
2063 if (handler instanceof ZonePlayerHandler) {
2064 return (ZonePlayerHandler) handler;
2068 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2071 public void setMute(Command command) {
2072 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2073 Map<String, String> inputs = new HashMap<>();
2074 inputs.put("Channel", "Master");
2076 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2077 inputs.put("DesiredMute", "True");
2078 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2079 || command.equals(OpenClosedType.CLOSED)) {
2080 inputs.put("DesiredMute", "False");
2083 Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetMute", inputs);
2085 for (String variable : result.keySet()) {
2086 this.onValueReceived(variable, result.get(variable), "RenderingControl");
2091 public List<SonosAlarm> getCurrentAlarmList() {
2092 Map<String, String> result = service.invokeAction(this, "AlarmClock", "ListAlarms", null);
2094 for (String variable : result.keySet()) {
2095 this.onValueReceived(variable, result.get(variable), "AlarmClock");
2098 String alarmList = result.get("CurrentAlarmList");
2099 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2102 public void updateAlarm(SonosAlarm alarm) {
2103 Map<String, String> inputs = new HashMap<>();
2106 inputs.put("ID", Integer.toString(alarm.getId()));
2107 inputs.put("StartLocalTime", alarm.getStartTime());
2108 inputs.put("Duration", alarm.getDuration());
2109 inputs.put("Recurrence", alarm.getRecurrence());
2110 inputs.put("RoomUUID", alarm.getRoomUUID());
2111 inputs.put("ProgramURI", alarm.getProgramURI());
2112 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2113 inputs.put("PlayMode", alarm.getPlayMode());
2114 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2115 if (alarm.getIncludeLinkedZones()) {
2116 inputs.put("IncludeLinkedZones", "1");
2118 inputs.put("IncludeLinkedZones", "0");
2121 if (alarm.getEnabled()) {
2122 inputs.put("Enabled", "1");
2124 inputs.put("Enabled", "0");
2126 } catch (NumberFormatException ex) {
2127 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2130 Map<String, String> result = service.invokeAction(this, "AlarmClock", "UpdateAlarm", inputs);
2132 for (String variable : result.keySet()) {
2133 this.onValueReceived(variable, result.get(variable), "AlarmClock");
2137 public void setAlarm(Command command) {
2138 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2139 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2141 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2142 || command.equals(OpenClosedType.CLOSED)) {
2148 public void setAlarm(boolean alarmSwitch) {
2149 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2151 // find the nearest alarm - take the current time from the Sonos system,
2152 // not the system where we are running
2153 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2154 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2156 String currentLocalTime = getTime();
2157 Date currentDateTime = null;
2159 currentDateTime = fmt.parse(currentLocalTime);
2160 } catch (ParseException e) {
2161 logger.debug("An exception occurred while formatting a date", e);
2164 if (currentDateTime != null) {
2165 Calendar currentDateTimeCalendar = Calendar.getInstance();
2166 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2167 currentDateTimeCalendar.setTime(currentDateTime);
2168 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2169 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2171 SonosAlarm firstAlarm = null;
2173 for (SonosAlarm anAlarm : sonosAlarms) {
2174 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2175 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2178 durationDate = durationFormat.parse(anAlarm.getDuration());
2179 } catch (ParseException e) {
2180 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2184 long duration = durationDate.getTime();
2186 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2187 shortestDuration = duration;
2188 firstAlarm = anAlarm;
2193 if (firstAlarm != null) {
2195 firstAlarm.setEnabled(true);
2197 firstAlarm.setEnabled(false);
2200 updateAlarm(firstAlarm);
2205 public @Nullable String getTime() {
2207 return stateMap.get("CurrentLocalTime");
2210 public @Nullable String getAlarmRunning() {
2211 return stateMap.get("AlarmRunning");
2214 public boolean isAlarmRunning() {
2215 return "1".equals(getAlarmRunning());
2218 public void snoozeAlarm(Command command) {
2219 if (isAlarmRunning() && command instanceof DecimalType) {
2220 int minutes = ((DecimalType) command).intValue();
2222 Map<String, String> inputs = new HashMap<>();
2224 Calendar snoozePeriod = Calendar.getInstance();
2225 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2226 snoozePeriod.setTimeInMillis(0);
2227 snoozePeriod.add(Calendar.MINUTE, minutes);
2228 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2229 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2232 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2233 } catch (NumberFormatException ex) {
2234 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2237 Map<String, String> result = service.invokeAction(this, "AVTransport", "SnoozeAlarm", inputs);
2239 for (String variable : result.keySet()) {
2240 this.onValueReceived(variable, result.get(variable), "AVTransport");
2243 logger.debug("There is no alarm running on {}", getUDN());
2247 public @Nullable String getAnalogLineInConnected() {
2248 return stateMap.get(LINEINCONNECTED);
2251 public boolean isAnalogLineInConnected() {
2252 return "true".equals(getAnalogLineInConnected());
2255 public @Nullable String getOpticalLineInConnected() {
2256 return stateMap.get(TOSLINEINCONNECTED);
2259 public boolean isOpticalLineInConnected() {
2260 return "true".equals(getOpticalLineInConnected());
2263 public void becomeStandAlonePlayer() {
2264 Map<String, String> result = service.invokeAction(this, "AVTransport", "BecomeCoordinatorOfStandaloneGroup",
2267 for (String variable : result.keySet()) {
2268 this.onValueReceived(variable, result.get(variable), "AVTransport");
2272 public void addMember(Command command) {
2273 if (command instanceof StringType) {
2274 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2276 getHandlerByName(command.toString()).setCurrentURI(entry);
2277 } catch (IllegalStateException e) {
2278 logger.debug("Cannot add group member ({})", e.getMessage());
2283 public boolean publicAddress(LineInType lineInType) {
2284 // check if sourcePlayer has a line-in connected
2285 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2286 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2287 // first remove this player from its own group if any
2288 becomeStandAlonePlayer();
2290 // add all other players to this new group
2291 for (SonosZoneGroup group : getZoneGroups()) {
2292 for (String player : group.getMembers()) {
2294 ZonePlayerHandler somePlayer = getHandlerByName(player);
2295 if (somePlayer != this) {
2296 somePlayer.becomeStandAlonePlayer();
2298 addMember(StringType.valueOf(somePlayer.getUDN()));
2300 } catch (IllegalStateException e) {
2301 logger.debug("Cannot add to group ({})", e.getMessage());
2307 ZonePlayerHandler coordinator = getCoordinatorHandler();
2308 // set the URI of the group to the line-in
2309 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2310 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2311 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2313 coordinator.setCurrentURI(entry);
2317 } catch (IllegalStateException e) {
2318 logger.debug("Cannot handle command ({})", e.getMessage());
2322 logger.debug("Line-in of {} is not connected", getUDN());
2328 * Play a given url to music in one of the music libraries.
2331 * in the format of //host/folder/filename.mp3
2333 public void playURI(Command command) {
2334 if (command instanceof StringType) {
2336 String url = command.toString();
2338 ZonePlayerHandler coordinator = getCoordinatorHandler();
2340 // stop whatever is currently playing
2342 coordinator.waitForNotTransportState(STATE_PLAYING);
2344 // clear any tracks which are pending in the queue
2345 coordinator.removeAllTracksFromQueue();
2347 // add the new track we want to play to the queue
2348 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2349 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2350 // default to file based url
2351 url = FILE_URI + url;
2353 coordinator.addURIToQueue(url, "", 0, true);
2355 // set the current playlist to our new queue
2356 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2358 // take the system off mute
2359 coordinator.setMute(OnOffType.OFF);
2363 } catch (IllegalStateException e) {
2364 logger.debug("Cannot play URI ({})", e.getMessage());
2369 private void scheduleNotificationSound(final Command command) {
2370 scheduler.submit(() -> {
2371 synchronized (notificationLock) {
2372 playNotificationSoundURI(command);
2378 * Play a given notification sound
2380 * @param url in the format of //host/folder/filename.mp3
2382 public void playNotificationSoundURI(Command notificationURL) {
2383 if (notificationURL instanceof StringType) {
2385 ZonePlayerHandler coordinator = getCoordinatorHandler();
2387 String currentURI = coordinator.getCurrentURI();
2388 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2389 coordinator.getCurrentURIMetadataAsString());
2391 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2392 || isPlayingRadio(currentURI)) {
2393 handleRadioStream(currentURI, notificationURL, coordinator);
2394 } else if (isPlayingLineIn(currentURI)) {
2395 handleLineIn(currentURI, notificationURL, coordinator);
2396 } else if (isPlayingQueue(currentURI)) {
2397 handleSharedQueue(currentURI, notificationURL, coordinator);
2398 } else if (isPlaylistEmpty(coordinator)) {
2399 handleEmptyQueue(notificationURL, coordinator);
2401 synchronized (notificationLock) {
2402 notificationLock.notify();
2404 } catch (IllegalStateException e) {
2405 logger.debug("Cannot play sound ({})", e.getMessage());
2410 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2411 return coordinator.getQueueSize() == 0;
2414 private boolean isPlayingQueue(@Nullable String currentURI) {
2415 return currentURI != null && currentURI.contains(QUEUE_URI);
2418 private boolean isPlayingStream(@Nullable String currentURI) {
2419 return currentURI != null && currentURI.contains(STREAM_URI);
2422 private boolean isPlayingRadio(@Nullable String currentURI) {
2423 return currentURI != null && currentURI.contains(RADIO_URI);
2426 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2427 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2430 private boolean isPlayingLineIn(@Nullable String currentURI) {
2431 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2434 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2435 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2438 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2439 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2443 * Does a chain of predefined actions when a Notification sound is played by
2444 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2445 * radio streaming is currently loaded
2447 * @param currentStreamURI - the currently loaded stream's URI
2448 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2449 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2451 private void handleRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2452 ZonePlayerHandler coordinator) {
2453 String nextAction = coordinator.getTransportState();
2454 SonosMetaData track = coordinator.getTrackMetadata();
2455 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2457 handleNotificationSound(notificationURL, coordinator);
2458 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2459 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2460 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2461 restoreLastTransportState(coordinator, nextAction);
2466 * Does a chain of predefined actions when a Notification sound is played by
2467 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2468 * line in is currently loaded
2470 * @param currentLineInURI - the currently loaded line-in URI
2471 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2472 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2474 private void handleLineIn(@Nullable String currentLineInURI, Command notificationURL,
2475 ZonePlayerHandler coordinator) {
2476 logger.debug("Handling notification while sound from line-in was being played");
2477 String nextAction = coordinator.getTransportState();
2479 handleNotificationSound(notificationURL, coordinator);
2480 if (currentLineInURI != null) {
2481 logger.debug("Restoring sound from line-in using {}", currentLineInURI);
2482 coordinator.setCurrentURI(currentLineInURI, "");
2483 restoreLastTransportState(coordinator, nextAction);
2488 * Does a chain of predefined actions when a Notification sound is played by
2489 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2490 * shared queue is currently loaded
2492 * @param currentQueueURI - the currently loaded queue URI
2493 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2494 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2496 private void handleSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2497 ZonePlayerHandler coordinator) {
2498 String nextAction = coordinator.getTransportState();
2499 String trackPosition = coordinator.getRefreshedPosition();
2500 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2501 logger.debug("handleSharedQueue: currentQueueURI {} trackPosition {} currentTrackNumber {}", currentQueueURI,
2502 trackPosition, currentTrackNumber);
2504 handleNotificationSound(notificationURL, coordinator);
2505 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2506 if (queueUri.equals(currentQueueURI)) {
2507 coordinator.setPositionTrack(currentTrackNumber);
2508 coordinator.setPosition(trackPosition);
2509 restoreLastTransportState(coordinator, nextAction);
2514 * Handle the execution of the notification sound by sequentially executing the required steps.
2516 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2517 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2519 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator) {
2520 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2521 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2522 if (sourceStoppable) {
2524 coordinator.waitForNotTransportState(STATE_PLAYING);
2525 applyNotificationSoundVolume();
2527 long notificationPosition = coordinator.getQueueSize() + 1;
2528 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2529 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2530 coordinator.setPositionTrack(notificationPosition);
2531 if (!sourceStoppable) {
2533 coordinator.waitForNotTransportState(STATE_PLAYING);
2534 applyNotificationSoundVolume();
2537 coordinator.waitForFinishedNotification();
2538 if (originalVolume != null) {
2539 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2541 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2544 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction) {
2545 if (nextAction != null) {
2546 switch (nextAction) {
2549 coordinator.waitForTransportState(STATE_PLAYING);
2551 case STATE_PAUSED_PLAYBACK:
2552 coordinator.pause();
2559 * Does a chain of predefined actions when a Notification sound is played by
2560 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2561 * empty queue is currently loaded
2563 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2564 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2566 private void handleEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator) {
2567 String originalVolume = coordinator.getVolume();
2568 coordinator.applyNotificationSoundVolume();
2569 coordinator.playURI(notificationURL);
2570 coordinator.waitForFinishedNotification();
2571 coordinator.removeAllTracksFromQueue();
2572 if (originalVolume != null) {
2573 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2578 * Applies the notification sound volume level to the group (if not null)
2580 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2582 private void applyNotificationSoundVolume() {
2583 setNotificationSoundVolume(getNotificationSoundVolume());
2586 private void waitForFinishedNotification() {
2587 waitForTransportState(STATE_PLAYING);
2589 // check Sonos state events to determine the end of the notification sound
2590 String notificationTitle = getCurrentTitle();
2591 long playstart = System.currentTimeMillis();
2592 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2595 String currentTitle = getCurrentTitle();
2596 if ((notificationTitle == null && currentTitle != null)
2597 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2598 || !STATE_PLAYING.equals(getTransportState())) {
2601 } catch (InterruptedException e) {
2602 logger.debug("InterruptedException during playing a notification sound");
2607 private void waitForTransportState(String state) {
2608 if (getTransportState() != null) {
2609 long start = System.currentTimeMillis();
2610 while (!state.equals(getTransportState())) {
2613 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2616 } catch (InterruptedException e) {
2617 logger.debug("InterruptedException during playing a notification sound");
2623 private void waitForNotTransportState(String state) {
2624 if (getTransportState() != null) {
2625 long start = System.currentTimeMillis();
2626 while (state.equals(getTransportState())) {
2629 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2632 } catch (InterruptedException e) {
2633 logger.debug("InterruptedException during playing a notification sound");
2640 * Removes a range of tracks from the queue.
2641 * (<x,y> will remove y songs started by the song number x)
2643 * @param command - must be in the format <startIndex, numberOfSongs>
2645 public void removeRangeOfTracksFromQueue(Command command) {
2646 if (command instanceof StringType) {
2647 Map<String, String> inputs = new HashMap<>();
2648 String[] rangeInputSplit = command.toString().split(",");
2650 // If range input is incorrect, remove the first song by default
2651 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2652 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2654 inputs.put("InstanceID", "0");
2655 inputs.put("StartingIndex", startIndex);
2656 inputs.put("NumberOfTracks", numberOfTracks);
2658 Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveTrackRangeFromQueue", inputs);
2660 for (String variable : result.keySet()) {
2661 this.onValueReceived(variable, result.get(variable), "AVTransport");
2666 public void clearQueue() {
2668 ZonePlayerHandler coordinator = getCoordinatorHandler();
2670 coordinator.removeAllTracksFromQueue();
2671 } catch (IllegalStateException e) {
2672 logger.debug("Cannot clear queue ({})", e.getMessage());
2676 public void playQueue() {
2678 ZonePlayerHandler coordinator = getCoordinatorHandler();
2680 // set the current playlist to our new queue
2681 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2683 // take the system off mute
2684 coordinator.setMute(OnOffType.OFF);
2688 } catch (IllegalStateException e) {
2689 logger.debug("Cannot play queue ({})", e.getMessage());
2693 public void setLed(Command command) {
2694 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2695 Map<String, String> inputs = new HashMap<>();
2697 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2698 inputs.put("DesiredLEDState", "On");
2699 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2700 || command.equals(OpenClosedType.CLOSED)) {
2701 inputs.put("DesiredLEDState", "Off");
2704 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "SetLEDState", inputs);
2705 Map<String, String> result2 = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
2707 result.putAll(result2);
2709 for (String variable : result.keySet()) {
2710 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
2715 public void removeMember(Command command) {
2716 if (command instanceof StringType) {
2718 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2720 oldmemberHandler.becomeStandAlonePlayer();
2721 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2722 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2723 oldmemberHandler.setCurrentURI(entry);
2724 } catch (IllegalStateException e) {
2725 logger.debug("Cannot remove group member ({})", e.getMessage());
2730 public void previous() {
2731 Map<String, String> result = service.invokeAction(this, "AVTransport", "Previous", null);
2733 for (String variable : result.keySet()) {
2734 this.onValueReceived(variable, result.get(variable), "AVTransport");
2738 public void next() {
2739 Map<String, String> result = service.invokeAction(this, "AVTransport", "Next", null);
2741 for (String variable : result.keySet()) {
2742 this.onValueReceived(variable, result.get(variable), "AVTransport");
2746 public void stopPlaying(Command command) {
2747 if (command instanceof OnOffType) {
2749 getCoordinatorHandler().stop();
2750 } catch (IllegalStateException e) {
2751 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2756 public void playRadio(Command command) {
2757 if (command instanceof StringType) {
2758 String station = command.toString();
2759 List<SonosEntry> stations = getFavoriteRadios();
2761 SonosEntry theEntry = null;
2762 // search for the appropriate radio based on its name (title)
2763 for (SonosEntry someStation : stations) {
2764 if (someStation.getTitle().equals(station)) {
2765 theEntry = someStation;
2770 // set the URI of the group coordinator
2771 if (theEntry != null) {
2773 ZonePlayerHandler coordinator = getCoordinatorHandler();
2774 coordinator.setCurrentURI(theEntry);
2776 } catch (IllegalStateException e) {
2777 logger.debug("Cannot play radio ({})", e.getMessage());
2780 logger.debug("Radio station '{}' not found", station);
2785 public void playTuneinStation(Command command) {
2786 if (command instanceof StringType) {
2787 String stationId = command.toString();
2788 List<SonosMusicService> allServices = getAvailableMusicServices();
2790 SonosMusicService tuneinService = null;
2791 // search for the TuneIn music service based on its name
2792 if (allServices != null) {
2793 for (SonosMusicService service : allServices) {
2794 if (service.getName().equals("TuneIn")) {
2795 tuneinService = service;
2801 // set the URI of the group coordinator
2802 if (tuneinService != null) {
2804 ZonePlayerHandler coordinator = getCoordinatorHandler();
2805 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
2806 "object.item.audioItem.audioBroadcast",
2807 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
2808 Integer tuneinServiceType = tuneinService.getType();
2809 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
2810 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
2811 coordinator.setCurrentURI(entry);
2813 } catch (IllegalStateException e) {
2814 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
2817 logger.debug("TuneIn service not found");
2822 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
2823 if (musicServices == null) {
2824 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
2826 String serviceList = result.get("AvailableServiceDescriptorList");
2827 if (serviceList != null) {
2828 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
2829 musicServices = services;
2831 String[] servicesTypes = new String[0];
2832 String serviceTypeList = result.get("AvailableServiceTypeList");
2833 if (serviceTypeList != null) {
2834 // It is a comma separated list of service types (integers) in the same order as the services
2835 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
2837 servicesTypes = serviceTypeList.split(",");
2841 for (SonosMusicService service : services) {
2842 if (!service.getName().equals("TuneIn")) {
2843 // Add the service type integer value from "AvailableServiceTypeList" to each service
2845 if (idx < servicesTypes.length) {
2847 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
2848 service.setType(serviceType);
2849 } catch (NumberFormatException e) {
2854 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
2856 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
2861 return musicServices;
2865 * This will attempt to match the station string with a entry in the
2866 * favorites list, this supports both single entries and playlists
2868 * @param favorite to match
2869 * @return true if a match was found and played.
2871 public void playFavorite(Command command) {
2872 if (command instanceof StringType) {
2873 String favorite = command.toString();
2874 List<SonosEntry> favorites = getFavorites();
2876 SonosEntry theEntry = null;
2877 // search for the appropriate favorite based on its name (title)
2878 for (SonosEntry entry : favorites) {
2879 if (entry.getTitle().equals(favorite)) {
2885 // set the URI of the group coordinator
2886 if (theEntry != null) {
2888 ZonePlayerHandler coordinator = getCoordinatorHandler();
2891 * If this is a playlist we need to treat it as such
2893 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
2894 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
2895 coordinator.removeAllTracksFromQueue();
2896 coordinator.addURIToQueue(theEntry);
2897 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2898 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
2899 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
2901 coordinator.setCurrentURI(theEntry);
2904 } catch (IllegalStateException e) {
2905 logger.debug("Cannot paly favorite ({})", e.getMessage());
2908 logger.debug("Favorite '{}' not found", favorite);
2913 public void playTrack(Command command) {
2914 if (command instanceof DecimalType) {
2916 ZonePlayerHandler coordinator = getCoordinatorHandler();
2918 String trackNumber = String.valueOf(((DecimalType) command).intValue());
2920 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2922 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
2923 coordinator.setPositionTrack(trackNumber);
2925 // take the system off mute
2926 coordinator.setMute(OnOffType.OFF);
2930 } catch (IllegalStateException e) {
2931 logger.debug("Cannot play track ({})", e.getMessage());
2936 public void playPlayList(Command command) {
2937 if (command instanceof StringType) {
2938 String playlist = command.toString();
2939 List<SonosEntry> playlists = getPlayLists();
2941 SonosEntry theEntry = null;
2942 // search for the appropriate play list based on its name (title)
2943 for (SonosEntry somePlaylist : playlists) {
2944 if (somePlaylist.getTitle().equals(playlist)) {
2945 theEntry = somePlaylist;
2950 // set the URI of the group coordinator
2951 if (theEntry != null) {
2953 ZonePlayerHandler coordinator = getCoordinatorHandler();
2955 coordinator.addURIToQueue(theEntry);
2957 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2959 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
2960 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
2963 } catch (IllegalStateException e) {
2964 logger.debug("Cannot play playlist ({})", e.getMessage());
2967 logger.debug("Playlist '{}' not found", playlist);
2972 public void addURIToQueue(SonosEntry newEntry) {
2973 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
2976 public @Nullable String getZoneName() {
2977 return stateMap.get("ZoneName");
2980 public @Nullable String getZoneGroupID() {
2981 return stateMap.get("LocalGroupUUID");
2984 public @Nullable String getRunningAlarmProperties() {
2985 return stateMap.get("RunningAlarmProperties");
2988 public @Nullable String getRefreshedRunningAlarmProperties() {
2989 updateRunningAlarmProperties();
2990 return getRunningAlarmProperties();
2993 public @Nullable String getMute() {
2994 return stateMap.get("MuteMaster");
2997 public boolean isMuted() {
2998 return "1".equals(getMute());
3001 public @Nullable String getLed() {
3002 return stateMap.get("CurrentLEDState");
3005 public boolean isLedOn() {
3006 return "On".equals(getLed());
3009 public @Nullable String getCurrentZoneName() {
3010 return stateMap.get("CurrentZoneName");
3013 public @Nullable String getRefreshedCurrentZoneName() {
3014 updateCurrentZoneName();
3015 return getCurrentZoneName();
3019 public void onStatusChanged(boolean status) {
3021 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3022 if (getThing().getStatus() != ThingStatus.ONLINE) {
3023 updateStatus(ThingStatus.ONLINE);
3024 scheduler.execute(this::poll);
3027 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3028 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3032 private @Nullable String getModelNameFromDescriptor() {
3033 URL descriptor = service.getDescriptorURL(this);
3034 if (descriptor != null) {
3035 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3036 return sonosModelDescription == null ? null : SonosXMLParser.extractModelName(sonosModelDescription);
3042 private boolean migrateThingType() {
3043 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3044 String modelName = getModelNameFromDescriptor();
3045 if (modelName != null && isSupportedModel(modelName)) {
3046 updateSonosThingType(modelName);
3053 private boolean isSupportedModel(String modelName) {
3054 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3055 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3062 private void updateSonosThingType(String newThingTypeID) {
3063 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3067 * Set the sleeptimer duration
3068 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3069 * Use empty String "" to switch the sleep timer off
3071 public void setSleepTimer(Command command) {
3072 if (command instanceof DecimalType) {
3073 Map<String, String> inputs = new HashMap<>();
3074 inputs.put("InstanceID", "0");
3075 inputs.put("NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue()));
3077 this.service.invokeAction(this, "AVTransport", "ConfigureSleepTimer", inputs);
3081 protected void updateSleepTimerDuration() {
3082 Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRemainingSleepTimerDuration", null);
3083 for (String variable : result.keySet()) {
3084 this.onValueReceived(variable, result.get(variable), "AVTransport");
3088 private String sleepSecondsToTimeStr(long sleepSeconds) {
3089 if (sleepSeconds == 0) {
3091 } else if (sleepSeconds < 68400) {
3092 long remainingSeconds = sleepSeconds;
3093 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3094 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3095 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3096 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3097 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3098 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3100 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3105 private long sleepStrTimeToSeconds(String sleepTime) {
3106 String[] units = sleepTime.split(":");
3107 int hours = Integer.parseInt(units[0]);
3108 int minutes = Integer.parseInt(units[1]);
3109 int seconds = Integer.parseInt(units[2]);
3110 return 3600 * hours + 60 * minutes + seconds;