2 * Copyright (c) 2010-2021 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 static final int MIN_SUBWOOFER_GAIN = -15;
115 private static final int MAX_SUBWOOFER_GAIN = 15;
116 private static final int MIN_SURROUND_LEVEL = -15;
117 private static final int MAX_SURROUND_LEVEL = 15;
119 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
121 private final ThingRegistry localThingRegistry;
122 private final UpnpIOService service;
123 private final @Nullable String opmlUrl;
124 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
126 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
129 * Intrinsic lock used to synchronize the execution of notification sounds
131 private final Object notificationLock = new Object();
132 private final Object upnpLock = new Object();
133 private final Object stateLock = new Object();
134 private final Object jobLock = new Object();
136 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
138 private @Nullable ScheduledFuture<?> pollingJob;
139 private @Nullable SonosZonePlayerState savedState;
141 private Map<String, Boolean> subscriptionState = new HashMap<>();
144 * Thing handler instance of the coordinator speaker used for control delegation
146 private @Nullable ZonePlayerHandler coordinatorHandler;
148 private @Nullable List<SonosMusicService> musicServices;
150 private enum LineInType {
156 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
157 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
159 this.localThingRegistry = thingRegistry;
160 this.opmlUrl = opmlUrl;
161 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
162 this.service = upnpIOService;
163 this.stateDescriptionProvider = stateDescriptionProvider;
167 public void dispose() {
168 logger.debug("Handler disposed for thing {}", getThing().getUID());
170 ScheduledFuture<?> job = this.pollingJob;
174 this.pollingJob = null;
176 removeSubscription();
177 service.unregisterParticipant(this);
181 public void initialize() {
182 logger.debug("initializing handler for thing {}", getThing().getUID());
184 if (migrateThingType()) {
185 // we change the type, so we might need a different handler -> let's finish
189 configuration = getConfigAs(ZonePlayerConfiguration.class);
190 String udn = configuration.udn;
191 if (udn != null && !udn.isEmpty()) {
192 service.registerParticipant(this);
193 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
195 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
196 "@text/offline.conf-error-missing-udn");
197 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
201 private void poll() {
202 synchronized (jobLock) {
203 if (pollingJob == null) {
207 logger.debug("Polling job");
209 // First check if the Sonos zone is set in the UPnP service registry
210 // If not, set the thing state to OFFLINE and wait for the next poll
211 if (!isUpnpDeviceRegistered()) {
212 logger.debug("UPnP device {} not yet registered", getUDN());
213 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
214 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
215 synchronized (upnpLock) {
216 subscriptionState = new HashMap<>();
221 // Check if the Sonos zone can be joined
222 // If not, set the thing state to OFFLINE and do nothing else
224 if (getThing().getStatus() != ThingStatus.ONLINE) {
230 if (isLinked(ZONENAME)) {
231 updateCurrentZoneName();
236 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
237 if (isLinked(SLEEPTIMER) && isCoordinator()) {
238 updateSleepTimerDuration();
240 } catch (Exception e) {
241 logger.debug("Exception during poll: {}", e.getMessage(), e);
247 public void handleCommand(ChannelUID channelUID, Command command) {
248 if (command == RefreshType.REFRESH) {
249 updateChannel(channelUID.getId());
251 switch (channelUID.getId()) {
258 case NOTIFICATIONSOUND:
259 scheduleNotificationSound(command);
262 stopPlaying(command);
265 setVolumeForGroup(command);
268 setSubwoofer(command);
271 setSubwooferGain(command);
274 setSurround(command);
276 case SURROUNDMUSICMODE:
277 setSurroundMusicMode(command);
279 case SURROUNDMUSICLEVEL:
280 setSurroundMusicLevel(command);
282 case SURROUNDTVLEVEL:
283 setSurroundTvLevel(command);
289 removeMember(command);
292 becomeStandAlonePlayer();
295 publicAddress(LineInType.ANY);
297 case PUBLICANALOGADDRESS:
298 publicAddress(LineInType.ANALOG);
300 case PUBLICDIGITALADDRESS:
301 publicAddress(LineInType.DIGITAL);
306 case TUNEINSTATIONID:
307 playTuneinStation(command);
310 playFavorite(command);
316 snoozeAlarm(command);
319 saveAllPlayerState();
322 restoreAllPlayerState();
331 playPlayList(command);
350 if (command instanceof PlayPauseType) {
351 if (command == PlayPauseType.PLAY) {
352 getCoordinatorHandler().play();
353 } else if (command == PlayPauseType.PAUSE) {
354 getCoordinatorHandler().pause();
357 if (command instanceof NextPreviousType) {
358 if (command == NextPreviousType.NEXT) {
359 getCoordinatorHandler().next();
360 } else if (command == NextPreviousType.PREVIOUS) {
361 getCoordinatorHandler().previous();
364 // Rewind and Fast Forward are currently not implemented by the binding
365 } catch (IllegalStateException e) {
366 logger.debug("Cannot handle control command ({})", e.getMessage());
370 setSleepTimer(command);
379 setNightMode(command);
381 case SPEECHENHANCEMENT:
382 setSpeechEnhancement(command);
390 private void restoreAllPlayerState() {
391 for (Thing aThing : localThingRegistry.getAll()) {
392 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
393 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
394 if (handler != null) {
395 handler.restoreState();
401 private void saveAllPlayerState() {
402 for (Thing aThing : localThingRegistry.getAll()) {
403 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
404 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
405 if (handler != null) {
413 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
414 if (variable == null || value == null || service == null) {
418 if (getThing().getStatus() == ThingStatus.ONLINE) {
419 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
420 new Object[] { variable, value, service, this.getThing().getUID() });
422 String oldValue = this.stateMap.get(variable);
423 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
427 this.stateMap.put(variable, value);
429 // pre-process some variables, eg XML processing
430 if (service.equals("AVTransport") && variable.equals("LastChange")) {
431 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
432 for (String parsedValue : parsedValues.keySet()) {
433 // Update the transport state after the update of the media information
434 // to not break the notification mechanism
435 if (!parsedValue.equals("TransportState")) {
436 onValueReceived(parsedValue, parsedValues.get(parsedValue), "AVTransport");
438 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
439 // for a compatibility with the result of the action GetMediaInfo
440 if (parsedValue.equals("AVTransportURI")) {
441 onValueReceived("CurrentURI", parsedValues.get(parsedValue), service);
442 } else if (parsedValue.equals("AVTransportURIMetaData")) {
443 onValueReceived("CurrentURIMetaData", parsedValues.get(parsedValue), service);
446 updateMediaInformation();
447 if (parsedValues.get("TransportState") != null) {
448 onValueReceived("TransportState", parsedValues.get("TransportState"), "AVTransport");
452 if (service.equals("RenderingControl") && variable.equals("LastChange")) {
453 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
454 for (String parsedValue : parsedValues.keySet()) {
455 onValueReceived(parsedValue, parsedValues.get(parsedValue), "RenderingControl");
459 List<StateOption> options = new ArrayList<>();
461 // update the appropriate channel
463 case "TransportState":
464 updateChannel(STATE);
465 updateChannel(CONTROL);
467 dispatchOnAllGroupMembers(variable, value, service);
469 case "CurrentPlayMode":
470 updateChannel(SHUFFLE);
471 updateChannel(REPEAT);
472 dispatchOnAllGroupMembers(variable, value, service);
474 case "CurrentLEDState":
478 updateState(ZONENAME, new StringType(value));
480 case "CurrentZoneName":
481 updateChannel(ZONENAME);
483 case "ZoneGroupState":
484 updateChannel(COORDINATOR);
485 // Update coordinator after a change is made to the grouping of Sonos players
486 updateGroupCoordinator();
487 updateMediaInformation();
488 // Update state and control channels for the group members with the coordinator values
489 String transportState = getTransportState();
490 if (transportState != null) {
491 dispatchOnAllGroupMembers("TransportState", transportState, "AVTransport");
493 // Update shuffle and repeat channels for the group members with the coordinator values
494 String playMode = getPlayMode();
495 if (playMode != null) {
496 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, "AVTransport");
499 case "LocalGroupUUID":
500 updateChannel(ZONEGROUPID);
502 case "GroupCoordinatorIsLocal":
503 updateChannel(LOCALCOORDINATOR);
506 updateChannel(VOLUME);
512 updateChannel(SUBWOOFER);
515 updateChannel(SUBWOOFERGAIN);
517 case "SurroundEnabled":
518 updateChannel(SURROUND);
521 updateChannel(SURROUNDMUSICMODE);
523 case "SurroundLevel":
524 updateChannel(SURROUNDTVLEVEL);
526 case "MusicSurroundLevel":
527 updateChannel(SURROUNDMUSICLEVEL);
530 updateChannel(NIGHTMODE);
533 updateChannel(SPEECHENHANCEMENT);
535 case LINEINCONNECTED:
536 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
537 updateChannel(LINEIN);
539 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
540 .contains(getThing().getThingTypeUID())) {
541 updateChannel(ANALOGLINEIN);
544 case TOSLINEINCONNECTED:
545 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
546 updateChannel(LINEIN);
548 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
549 .contains(getThing().getThingTypeUID())) {
550 updateChannel(DIGITALLINEIN);
554 updateChannel(ALARMRUNNING);
555 updateRunningAlarmProperties();
557 case "RunningAlarmProperties":
558 updateChannel(ALARMPROPERTIES);
560 case "CurrentURIFormatted":
561 updateChannel(CURRENTTRACK);
564 updateChannel(CURRENTTITLE);
566 case "CurrentArtist":
567 updateChannel(CURRENTARTIST);
570 updateChannel(CURRENTALBUM);
573 updateChannel(CURRENTTRANSPORTURI);
575 case "CurrentTrackURI":
576 updateChannel(CURRENTTRACKURI);
578 case "CurrentAlbumArtURI":
579 updateChannel(CURRENTALBUMARTURL);
581 case "CurrentSleepTimerGeneration":
582 if (value.equals("0")) {
583 updateState(SLEEPTIMER, new DecimalType(0));
586 case "SleepTimerGeneration":
587 if (value.equals("0")) {
588 updateState(SLEEPTIMER, new DecimalType(0));
590 updateSleepTimerDuration();
593 case "RemainingSleepTimerDuration":
594 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
596 case "CurrentTuneInStationId":
597 updateChannel(TUNEINSTATIONID);
599 case "SavedQueuesUpdateID": // service ContentDirectoy
600 for (SonosEntry entry : getPlayLists()) {
601 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
603 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
605 case "FavoritesUpdateID": // service ContentDirectoy
606 for (SonosEntry entry : getFavorites()) {
607 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
609 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
611 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
612 // Due to a bug in the data type definition of this state variable, it is not set.
613 // As a workaround, we check the state variable named ContainerUpdateIDs.
614 case "ContainerUpdateIDs": // service ContentDirectoy
615 if (value.startsWith("R:0,") || stateDescriptionProvider
616 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
617 for (SonosEntry entry : getFavoriteRadios()) {
618 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
620 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
629 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
630 if (isCoordinator()) {
631 for (String member : getOtherZoneGroupMembers()) {
633 ZonePlayerHandler memberHandler = getHandlerByName(member);
634 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
635 memberHandler.onValueReceived(variable, value, service);
637 } catch (IllegalStateException e) {
638 logger.debug("Cannot update channel for group member ({})", e.getMessage());
644 private @Nullable String getAlbumArtUrl() {
646 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
647 if (albumArtURI != null) {
648 if (albumArtURI.startsWith("http")) {
650 } else if (albumArtURI.startsWith("/")) {
652 URL serviceDescrUrl = service.getDescriptorURL(this);
653 if (serviceDescrUrl != null) {
654 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
655 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
657 } catch (MalformedURLException e) {
658 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
665 protected void updateChannel(String channelId) {
666 if (!isLinked(channelId)) {
672 State newState = UnDefType.UNDEF;
676 value = getTransportState();
678 newState = new StringType(value);
682 value = getTransportState();
683 if (STATE_PLAYING.equals(value)) {
684 newState = PlayPauseType.PLAY;
685 } else if (STATE_STOPPED.equals(value)) {
686 newState = PlayPauseType.PAUSE;
687 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
688 newState = PlayPauseType.PAUSE;
692 value = getTransportState();
694 newState = STATE_STOPPED.equals(value) ? OnOffType.ON : OnOffType.OFF;
698 if (getPlayMode() != null) {
699 newState = isShuffleActive() ? OnOffType.ON : OnOffType.OFF;
703 if (getPlayMode() != null) {
704 newState = new StringType(getRepeatMode());
708 if (getLed() != null) {
709 newState = isLedOn() ? OnOffType.ON : OnOffType.OFF;
713 value = getCurrentZoneName();
715 newState = new StringType(value);
719 value = getZoneGroupID();
721 newState = new StringType(value);
725 newState = new StringType(getCoordinator());
727 case LOCALCOORDINATOR:
728 if (getGroupCoordinatorIsLocal() != null) {
729 newState = isGroupCoordinator() ? OnOffType.ON : OnOffType.OFF;
735 newState = new PercentType(value);
741 newState = isMuted() ? OnOffType.ON : OnOffType.OFF;
745 value = getSubwooferEnabled();
747 newState = OnOffType.from(value);
751 value = getSubwooferGain();
753 newState = new DecimalType(value);
757 value = getSurroundEnabled();
759 newState = OnOffType.from(value);
762 case SURROUNDMUSICMODE:
763 value = getSurroundMusicMode();
765 newState = new StringType(value);
768 case SURROUNDMUSICLEVEL:
769 value = getSurroundMusicLevel();
771 newState = new DecimalType(value);
774 case SURROUNDTVLEVEL:
775 value = getSurroundTvLevel();
777 newState = new DecimalType(value);
781 value = getNightMode();
783 newState = isNightModeOn() ? OnOffType.ON : OnOffType.OFF;
786 case SPEECHENHANCEMENT:
787 value = getDialogLevel();
789 newState = isSpeechEnhanced() ? OnOffType.ON : OnOffType.OFF;
793 if (getAnalogLineInConnected() != null) {
794 newState = isAnalogLineInConnected() ? OnOffType.ON : OnOffType.OFF;
795 } else if (getOpticalLineInConnected() != null) {
796 newState = isOpticalLineInConnected() ? OnOffType.ON : OnOffType.OFF;
800 if (getAnalogLineInConnected() != null) {
801 newState = isAnalogLineInConnected() ? OnOffType.ON : OnOffType.OFF;
805 if (getOpticalLineInConnected() != null) {
806 newState = isOpticalLineInConnected() ? OnOffType.ON : OnOffType.OFF;
810 if (getAlarmRunning() != null) {
811 newState = isAlarmRunning() ? OnOffType.ON : OnOffType.OFF;
814 case ALARMPROPERTIES:
815 value = getRunningAlarmProperties();
817 newState = new StringType(value);
821 value = stateMap.get("CurrentURIFormatted");
823 newState = new StringType(value);
827 value = getCurrentTitle();
829 newState = new StringType(value);
833 value = getCurrentArtist();
835 newState = new StringType(value);
839 value = getCurrentAlbum();
841 newState = new StringType(value);
844 case CURRENTALBUMART:
846 updateAlbumArtChannel(false);
848 case CURRENTALBUMARTURL:
849 url = getAlbumArtUrl();
851 newState = new StringType(url);
854 case CURRENTTRANSPORTURI:
855 value = getCurrentURI();
857 newState = new StringType(value);
860 case CURRENTTRACKURI:
861 value = stateMap.get("CurrentTrackURI");
863 newState = new StringType(value);
866 case TUNEINSTATIONID:
867 value = stateMap.get("CurrentTuneInStationId");
869 newState = new StringType(value);
876 if (newState != null) {
877 updateState(channelId, newState);
881 private void updateAlbumArtChannel(boolean allGroup) {
882 String url = getAlbumArtUrl();
884 // We download the cover art in a different thread to not delay the other operations
885 scheduler.submit(() -> {
886 RawType image = HttpUtil.downloadImage(url, true, 500000);
887 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
890 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
894 private void updateChannel(String channeldD, State state, boolean allGroup) {
896 for (String member : getZoneGroupMembers()) {
898 ZonePlayerHandler memberHandler = getHandlerByName(member);
899 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
900 && memberHandler.isLinked(channeldD)) {
901 memberHandler.updateState(channeldD, state);
903 } catch (IllegalStateException e) {
904 logger.debug("Cannot update channel for group member ({})", e.getMessage());
907 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
908 updateState(channeldD, state);
913 * CurrentURI will not change, but will trigger change of CurrentURIFormated
914 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
916 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
917 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
920 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
921 return oldValue != null ? !oldValue.equals(value) : value != null;
925 * Similar to the AVTransport eventing, the Queue events its state variables
926 * as sub values within a synthesized LastChange state variable.
928 private boolean isQueueEvent(String variable) {
929 return "LastChange".equals(variable);
932 private void updateGroupCoordinator() {
934 coordinatorHandler = getHandlerByName(getCoordinator());
935 } catch (IllegalStateException e) {
936 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
937 coordinatorHandler = null;
941 private boolean isUpnpDeviceRegistered() {
942 return service.isRegistered(this);
945 private void addSubscription() {
946 synchronized (upnpLock) {
947 // Set up GENA Subscriptions
948 if (service.isRegistered(this)) {
949 for (String subscription : SERVICE_SUBSCRIPTIONS) {
950 Boolean state = subscriptionState.get(subscription);
951 if (state == null || !state) {
952 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
953 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
954 subscriptionState.put(subscription, true);
961 private void removeSubscription() {
962 synchronized (upnpLock) {
963 // Set up GENA Subscriptions
964 if (service.isRegistered(this)) {
965 for (String subscription : SERVICE_SUBSCRIPTIONS) {
966 Boolean state = subscriptionState.get(subscription);
967 if (state != null && state) {
968 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
969 service.removeSubscription(this, subscription);
973 subscriptionState = new HashMap<>();
978 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
979 if (service == null) {
982 synchronized (upnpLock) {
983 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
984 subscriptionState.put(service, succeeded);
988 private void updatePlayerState() {
989 if (!updateZoneInfo()) {
990 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
991 logger.debug("Sonos player {} is not available in local network", getUDN());
992 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
993 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
994 synchronized (upnpLock) {
995 subscriptionState = new HashMap<>();
998 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
999 logger.debug("Sonos player {} has been found in local network", getUDN());
1000 updateStatus(ThingStatus.ONLINE);
1004 protected void updateCurrentZoneName() {
1005 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneAttributes", null);
1007 for (String variable : result.keySet()) {
1008 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
1012 protected void updateLed() {
1013 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
1015 for (String variable : result.keySet()) {
1016 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
1020 protected void updateTime() {
1021 Map<String, String> result = service.invokeAction(this, "AlarmClock", "GetTimeNow", null);
1023 for (String variable : result.keySet()) {
1024 this.onValueReceived(variable, result.get(variable), "AlarmClock");
1028 protected void updatePosition() {
1029 Map<String, String> result = service.invokeAction(this, "AVTransport", "GetPositionInfo", null);
1031 for (String variable : result.keySet()) {
1032 this.onValueReceived(variable, result.get(variable), "AVTransport");
1036 protected void updateRunningAlarmProperties() {
1037 Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRunningAlarmProperties", null);
1039 String alarmID = result.get("AlarmID");
1040 String loggedStartTime = result.get("LoggedStartTime");
1041 String newStringValue = null;
1042 if (alarmID != null && loggedStartTime != null) {
1043 newStringValue = alarmID + " - " + loggedStartTime;
1045 newStringValue = "No running alarm";
1047 result.put("RunningAlarmProperties", newStringValue);
1049 for (String variable : result.keySet()) {
1050 this.onValueReceived(variable, result.get(variable), "AVTransport");
1054 protected boolean updateZoneInfo() {
1055 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneInfo", null);
1056 for (String variable : result.keySet()) {
1057 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
1060 Map<String, String> properties = editProperties();
1061 String value = stateMap.get("HardwareVersion");
1062 if (value != null && !value.isEmpty()) {
1063 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1065 value = stateMap.get("DisplaySoftwareVersion");
1066 if (value != null && !value.isEmpty()) {
1067 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1069 value = stateMap.get("SerialNumber");
1070 if (value != null && !value.isEmpty()) {
1071 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1073 value = stateMap.get("MACAddress");
1074 if (value != null && !value.isEmpty()) {
1075 properties.put(MAC_ADDRESS, value);
1077 value = stateMap.get("IPAddress");
1078 if (value != null && !value.isEmpty()) {
1079 properties.put(IP_ADDRESS, value);
1081 updateProperties(properties);
1083 return !result.isEmpty();
1086 public String getCoordinator() {
1087 for (SonosZoneGroup zg : getZoneGroups()) {
1088 if (zg.getMembers().contains(getUDN())) {
1089 return zg.getCoordinator();
1095 public boolean isCoordinator() {
1096 return getUDN().equals(getCoordinator());
1099 protected void updateMediaInformation() {
1100 String currentURI = getCurrentURI();
1101 SonosMetaData currentTrack = getTrackMetadata();
1102 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1104 String artist = null;
1105 String album = null;
1106 String title = null;
1107 String resultString = null;
1108 String stationID = null;
1109 boolean needsUpdating = false;
1111 // if currentURI == null, we do nothing
1112 if (currentURI != null) {
1113 if (currentURI.isEmpty()) {
1115 needsUpdating = true;
1118 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1119 // The Sonos is a slave member of a group
1120 // The media information will be updated by the coordinator
1121 // Notification of group change occurs later, so we just check the URI
1123 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1124 // Radio stream (tune-in)
1125 boolean opmlUrlSucceeded = false;
1126 stationID = extractStationId(currentURI);
1127 String url = opmlUrl;
1129 String mac = getMACAddress();
1130 if (stationID != null && !stationID.isEmpty() && mac != null && !mac.isEmpty()) {
1131 url = url.replace("%id", stationID);
1132 url = url.replace("%serial", mac);
1134 String response = null;
1136 response = HttpUtil.executeUrl("GET", url, SOCKET_TIMEOUT);
1137 } catch (IOException e) {
1138 logger.debug("Request to device failed", e);
1141 if (response != null) {
1142 List<String> fields = SonosXMLParser.getRadioTimeFromXML(response);
1144 if (!fields.isEmpty()) {
1145 opmlUrlSucceeded = true;
1148 for (String field : fields) {
1149 if (resultString.isEmpty()) {
1150 // radio name should be first field
1153 resultString += " - ";
1155 resultString += field;
1158 needsUpdating = true;
1163 if (!opmlUrlSucceeded) {
1164 if (currentUriMetaData != null) {
1165 title = currentUriMetaData.getTitle();
1166 if (currentTrack == null || currentTrack.getStreamContent().isEmpty()) {
1167 resultString = title;
1169 resultString = title + " - " + currentTrack.getStreamContent();
1171 needsUpdating = true;
1176 else if (isPlayingLineIn(currentURI)) {
1177 if (currentTrack != null) {
1178 title = currentTrack.getTitle();
1179 resultString = title;
1180 needsUpdating = true;
1184 else if (isPlayingRadio(currentURI)
1185 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1186 // isPlayingRadio(currentURI) is true for Google Play Music radio or Apple Music radio
1187 if (currentTrack != null) {
1188 artist = !currentTrack.getAlbumArtist().isEmpty() ? currentTrack.getAlbumArtist()
1189 : currentTrack.getCreator();
1190 album = currentTrack.getAlbum();
1191 title = currentTrack.getTitle();
1192 resultString = artist + " - " + album + " - " + title;
1193 needsUpdating = true;
1198 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1199 ? currentTrack.getAlbumArtUri()
1202 ZonePlayerHandler handlerForImageUpdate = null;
1203 for (String member : getZoneGroupMembers()) {
1205 ZonePlayerHandler memberHandler = getHandlerByName(member);
1206 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1207 if (memberHandler.isLinked(CURRENTALBUMART)
1208 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1209 handlerForImageUpdate = memberHandler;
1211 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1213 if (needsUpdating) {
1214 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "", "AVTransport");
1215 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "", "AVTransport");
1216 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "", "AVTransport");
1217 memberHandler.onValueReceived("CurrentURIFormatted", (resultString != null) ? resultString : "",
1219 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, "AVTransport");
1222 } catch (IllegalStateException e) {
1223 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1226 if (needsUpdating && handlerForImageUpdate != null) {
1227 handlerForImageUpdate.updateAlbumArtChannel(true);
1231 private @Nullable String extractStationId(String uri) {
1232 String stationID = null;
1233 if (isPlayingStream(uri)) {
1234 stationID = substringBetween(uri, ":s", "?sid");
1235 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1236 stationID = substringBetween(uri, "sid=s", "&");
1241 private @Nullable String substringBetween(String str, String open, String close) {
1242 String result = null;
1243 int idx1 = str.indexOf(open);
1245 idx1 += open.length();
1246 int idx2 = str.indexOf(close, idx1);
1248 result = str.substring(idx1, idx2);
1254 public @Nullable String getGroupCoordinatorIsLocal() {
1255 return stateMap.get("GroupCoordinatorIsLocal");
1258 public boolean isGroupCoordinator() {
1259 return "true".equals(getGroupCoordinatorIsLocal());
1263 public String getUDN() {
1264 String udn = configuration.udn;
1265 return udn != null && !udn.isEmpty() ? udn : "undefined";
1268 public @Nullable String getCurrentURI() {
1269 return stateMap.get("CurrentURI");
1272 public @Nullable String getCurrentURIMetadataAsString() {
1273 return stateMap.get("CurrentURIMetaData");
1276 public @Nullable SonosMetaData getCurrentURIMetadata() {
1277 String metaData = getCurrentURIMetadataAsString();
1278 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1281 public @Nullable SonosMetaData getTrackMetadata() {
1282 String metaData = stateMap.get("CurrentTrackMetaData");
1283 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1286 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1287 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1288 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1291 public @Nullable String getMACAddress() {
1292 String mac = stateMap.get("MACAddress");
1293 if (mac == null || mac.isEmpty()) {
1296 return stateMap.get("MACAddress");
1299 public @Nullable String getRefreshedPosition() {
1301 return stateMap.get("RelTime");
1304 public long getRefreshedCurrenTrackNr() {
1306 String value = stateMap.get("Track");
1307 if (value != null) {
1308 return Long.valueOf(value);
1314 public @Nullable String getVolume() {
1315 return stateMap.get("VolumeMaster");
1318 public @Nullable String getSurroundEnabled() {
1319 return stateMap.get("SurroundEnabled");
1322 public @Nullable String getSurroundMusicMode() {
1323 return stateMap.get("SurroundMode");
1326 public @Nullable String getSurroundTvLevel() {
1327 return stateMap.get("SurroundLevel");
1330 public @Nullable String getSurroundMusicLevel() {
1331 return stateMap.get("MusicSurroundLevel");
1334 public @Nullable String getSubwooferEnabled() {
1335 return stateMap.get("SubEnabled");
1338 public @Nullable String getSubwooferGain() {
1339 return stateMap.get("SubGain");
1342 public @Nullable String getTransportState() {
1343 return stateMap.get("TransportState");
1346 public @Nullable String getCurrentTitle() {
1347 return stateMap.get("CurrentTitle");
1350 public @Nullable String getCurrentArtist() {
1351 return stateMap.get("CurrentArtist");
1354 public @Nullable String getCurrentAlbum() {
1355 return stateMap.get("CurrentAlbum");
1358 public List<SonosEntry> getArtists(String filter) {
1359 return getEntries("A:", filter);
1362 public List<SonosEntry> getArtists() {
1363 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1366 public List<SonosEntry> getAlbums(String filter) {
1367 return getEntries("A:ALBUM", filter);
1370 public List<SonosEntry> getAlbums() {
1371 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1374 public List<SonosEntry> getTracks(String filter) {
1375 return getEntries("A:TRACKS", filter);
1378 public List<SonosEntry> getTracks() {
1379 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1382 public List<SonosEntry> getQueue(String filter) {
1383 return getEntries("Q:0", filter);
1386 public List<SonosEntry> getQueue() {
1387 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1390 public long getQueueSize() {
1391 return getNbEntries("Q:0");
1394 public List<SonosEntry> getPlayLists(String filter) {
1395 return getEntries("SQ:", filter);
1398 public List<SonosEntry> getPlayLists() {
1399 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1402 public List<SonosEntry> getFavoriteRadios(String filter) {
1403 return getEntries("R:0/0", filter);
1406 public List<SonosEntry> getFavoriteRadios() {
1407 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1411 * Searches for entries in the 'favorites' list on a sonos account
1415 public List<SonosEntry> getFavorites() {
1416 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1419 protected List<SonosEntry> getEntries(String type, String filter) {
1422 Map<String, String> inputs = new HashMap<>();
1423 inputs.put("ObjectID", type);
1424 inputs.put("BrowseFlag", "BrowseDirectChildren");
1425 inputs.put("Filter", filter);
1426 inputs.put("StartingIndex", Long.toString(startAt));
1427 inputs.put("RequestedCount", Integer.toString(200));
1428 inputs.put("SortCriteria", "");
1430 Map<String, String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1432 String initialResult = result.get("Result");
1433 if (initialResult == null) {
1434 return Collections.emptyList();
1437 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1438 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1440 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1441 startAt = startAt + initialNumberReturned;
1443 while (startAt < totalMatches) {
1444 inputs.put("StartingIndex", Long.toString(startAt));
1445 result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1447 // Execute this action synchronously
1448 String nextResult = result.get("Result");
1449 if (nextResult == null) {
1453 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1455 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1457 startAt = startAt + numberReturned;
1463 protected long getNbEntries(String type) {
1464 Map<String, String> inputs = new HashMap<>();
1465 inputs.put("ObjectID", type);
1466 inputs.put("BrowseFlag", "BrowseDirectChildren");
1467 inputs.put("Filter", "dc:title");
1468 inputs.put("StartingIndex", "0");
1469 inputs.put("RequestedCount", "1");
1470 inputs.put("SortCriteria", "");
1472 Map<String, String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1474 return getResultEntry(result, "TotalMatches", type, "dc:title");
1478 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1480 * @param resultInput - the map to be examined for the requestedKey
1481 * @param requestedKey - the key to be sought in the resultInput map
1482 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1483 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1485 * @return 0 as long or the value corresponding to the requiredKey if found
1487 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1488 String entriesFilter) {
1491 if (resultInput.isEmpty()) {
1496 String resultString = resultInput.get(requestedKey);
1497 if (resultString == null) {
1498 throw new NumberFormatException("Requested key is null.");
1500 result = Long.valueOf(resultString);
1501 } catch (NumberFormatException ex) {
1502 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1503 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1510 * Save the state (track, position etc) of the Sonos Zone player.
1512 * @return true if no error occurred.
1514 protected void saveState() {
1515 synchronized (stateLock) {
1516 savedState = new SonosZonePlayerState();
1517 String currentURI = getCurrentURI();
1519 savedState.transportState = getTransportState();
1520 savedState.volume = getVolume();
1522 if (currentURI != null) {
1523 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
1524 || isPlayingRadio(currentURI)) {
1525 // we are streaming music, like tune-in radio or Google Play Music radio
1526 SonosMetaData track = getTrackMetadata();
1527 SonosMetaData current = getCurrentURIMetadata();
1528 if (track != null && current != null) {
1529 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1530 current.getUpnpClass(), currentURI);
1532 } else if (currentURI.contains(GROUP_URI)) {
1533 // we are a slave to some coordinator
1534 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1535 } else if (isPlayingLineIn(currentURI)) {
1536 // we are streaming from the Line In connection
1537 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1538 } else if (isPlayingQueue(currentURI)) {
1539 // we are playing something that sits in the queue
1540 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1541 if (queued != null) {
1542 savedState.track = getRefreshedCurrenTrackNr();
1544 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1545 // we are playing a real 'saved' playlist
1546 List<SonosEntry> playLists = getPlayLists();
1547 for (SonosEntry someList : playLists) {
1548 if (someList.getTitle().equals(queued.getTitle())) {
1549 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1550 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1555 } else if (queued.getUpnpClass().contains("object.container")) {
1556 // we are playing some other sort of
1557 // 'container' - we will save that to a
1558 // playlist for our convenience
1559 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1561 // save the playlist
1562 String existingList = "";
1563 List<SonosEntry> playLists = getPlayLists();
1564 for (SonosEntry someList : playLists) {
1565 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1566 existingList = someList.getId();
1571 saveQueue(TITLE_PREFIX + getUDN(), existingList);
1573 // get all the playlists and a ref to our
1575 playLists = getPlayLists();
1576 for (SonosEntry someList : playLists) {
1577 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1578 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1579 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1586 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1590 savedState.relTime = getRefreshedPosition();
1592 savedState.entry = null;
1598 * Restore the state (track, position etc) of the Sonos Zone player.
1600 * @return true if no error occurred.
1602 protected void restoreState() {
1603 synchronized (stateLock) {
1604 SonosZonePlayerState state = savedState;
1605 if (state != null) {
1606 // put settings back
1607 String volume = state.volume;
1608 if (volume != null) {
1609 setVolume(DecimalType.valueOf(volume));
1612 if (isCoordinator()) {
1613 SonosEntry entry = state.entry;
1614 if (entry != null) {
1615 // check if we have a playlist to deal with
1616 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1617 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1618 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1619 setCurrentURI(entry);
1620 setPositionTrack(state.track);
1622 setCurrentURI(entry);
1623 setPosition(state.relTime);
1627 String transportState = state.transportState;
1628 if (transportState != null) {
1629 if (transportState.equals(STATE_PLAYING)) {
1631 } else if (transportState.equals(STATE_STOPPED)) {
1633 } else if (transportState.equals(STATE_PAUSED_PLAYBACK)) {
1642 public void saveQueue(String name, String queueID) {
1643 Map<String, String> inputs = new HashMap<>();
1644 inputs.put("Title", name);
1645 inputs.put("ObjectID", queueID);
1647 Map<String, String> result = service.invokeAction(this, "AVTransport", "SaveQueue", inputs);
1649 for (String variable : result.keySet()) {
1650 this.onValueReceived(variable, result.get(variable), "AVTransport");
1654 public void setVolume(Command command) {
1655 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1656 || command instanceof PercentType) {
1657 Map<String, String> inputs = new HashMap<>();
1659 String newValue = null;
1660 String currentVolume = getVolume();
1661 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1662 int i = Integer.valueOf(currentVolume);
1663 newValue = String.valueOf(Math.min(100, i + 1));
1664 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1665 int i = Integer.valueOf(currentVolume);
1666 newValue = String.valueOf(Math.max(0, i - 1));
1667 } else if (command == OnOffType.ON) {
1669 } else if (command == OnOffType.OFF) {
1671 } else if (command instanceof DecimalType) {
1672 newValue = String.valueOf(((DecimalType) command).intValue());
1676 inputs.put("Channel", "Master");
1677 inputs.put("DesiredVolume", newValue);
1679 Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetVolume", inputs);
1681 for (String variable : result.keySet()) {
1682 this.onValueReceived(variable, result.get(variable), "RenderingControl");
1688 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1689 * AdHoc groups handles the volume specifically for each player.
1690 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1692 public void setVolumeForGroup(Command command) {
1693 if (isAdHocGroup() || isStandalonePlayer()) {
1697 getCoordinatorHandler().setVolume(command);
1698 } catch (IllegalStateException e) {
1699 logger.debug("Cannot set group volume ({})", e.getMessage());
1705 * Checks if the player receiving the command is part of a group that
1706 * consists of randomly added players or contains bonded players
1710 private boolean isAdHocGroup() {
1711 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1712 if (currentZoneGroup != null) {
1713 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1715 for (String zoneName : zoneGroupMemberNames) {
1716 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1717 // At least one "ZoneName" differs so we have an AdHoc group
1726 * Checks if the player receiving the command is a standalone player
1730 private boolean isStandalonePlayer() {
1731 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1732 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1735 private Collection<SonosZoneGroup> getZoneGroups() {
1736 String zoneGroupState = stateMap.get("ZoneGroupState");
1737 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1741 * Returns the current zone group
1742 * (of which the player receiving the command is part)
1744 * @return {@link SonosZoneGroup}
1746 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1747 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1748 if (zoneGroup.getMembers().contains(getUDN())) {
1752 logger.debug("Could not fetch Sonos group state information");
1757 * Sets the volume level for a notification sound
1759 * @param notificationSoundVolume
1761 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1762 if (notificationSoundVolume != null) {
1763 setVolumeForGroup(notificationSoundVolume);
1768 * Gets the volume level for a notification sound
1770 public @Nullable PercentType getNotificationSoundVolume() {
1771 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1772 if (notificationSoundVolume == null) {
1773 // if no value is set we use the current volume instead
1774 String volume = getVolume();
1775 return volume != null ? new PercentType(volume) : null;
1777 return new PercentType(notificationSoundVolume);
1780 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1781 Map<String, String> inputs = new HashMap<>();
1784 inputs.put("InstanceID", "0");
1785 inputs.put("EnqueuedURI", URI);
1786 inputs.put("EnqueuedURIMetaData", meta);
1787 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1788 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1789 } catch (NumberFormatException ex) {
1790 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1793 Map<String, String> result = service.invokeAction(this, "AVTransport", "AddURIToQueue", inputs);
1795 for (String variable : result.keySet()) {
1796 this.onValueReceived(variable, result.get(variable), "AVTransport");
1800 public void setCurrentURI(SonosEntry newEntry) {
1801 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1804 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1805 if (URI != null && URIMetaData != null) {
1806 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1807 Map<String, String> inputs = new HashMap<>();
1810 inputs.put("InstanceID", "0");
1811 inputs.put("CurrentURI", URI);
1812 inputs.put("CurrentURIMetaData", URIMetaData);
1813 } catch (NumberFormatException ex) {
1814 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1817 Map<String, String> result = service.invokeAction(this, "AVTransport", "SetAVTransportURI", inputs);
1819 for (String variable : result.keySet()) {
1820 this.onValueReceived(variable, result.get(variable), "AVTransport");
1825 public void setPosition(@Nullable String relTime) {
1826 seek("REL_TIME", relTime);
1829 public void setPositionTrack(long tracknr) {
1830 seek("TRACK_NR", Long.toString(tracknr));
1833 public void setPositionTrack(String tracknr) {
1834 seek("TRACK_NR", tracknr);
1837 protected void seek(String unit, @Nullable String target) {
1838 if (target != null) {
1839 Map<String, String> inputs = new HashMap<>();
1842 inputs.put("InstanceID", "0");
1843 inputs.put("Unit", unit);
1844 inputs.put("Target", target);
1845 } catch (NumberFormatException ex) {
1846 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1849 Map<String, String> result = service.invokeAction(this, "AVTransport", "Seek", inputs);
1851 for (String variable : result.keySet()) {
1852 this.onValueReceived(variable, result.get(variable), "AVTransport");
1857 public void play() {
1858 Map<String, String> inputs = new HashMap<>();
1859 inputs.put("Speed", "1");
1861 Map<String, String> result = service.invokeAction(this, "AVTransport", "Play", inputs);
1863 for (String variable : result.keySet()) {
1864 this.onValueReceived(variable, result.get(variable), "AVTransport");
1868 public void stop() {
1869 Map<String, String> result = service.invokeAction(this, "AVTransport", "Stop", null);
1871 for (String variable : result.keySet()) {
1872 this.onValueReceived(variable, result.get(variable), "AVTransport");
1876 public void pause() {
1877 Map<String, String> result = service.invokeAction(this, "AVTransport", "Pause", null);
1879 for (String variable : result.keySet()) {
1880 this.onValueReceived(variable, result.get(variable), "AVTransport");
1884 public void setShuffle(Command command) {
1885 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1887 ZonePlayerHandler coordinator = getCoordinatorHandler();
1889 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1890 || command.equals(OpenClosedType.OPEN)) {
1891 switch (coordinator.getRepeatMode()) {
1893 coordinator.updatePlayMode("SHUFFLE");
1896 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
1899 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
1902 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
1903 || command.equals(OpenClosedType.CLOSED)) {
1904 switch (coordinator.getRepeatMode()) {
1906 coordinator.updatePlayMode("REPEAT_ALL");
1909 coordinator.updatePlayMode("REPEAT_ONE");
1912 coordinator.updatePlayMode("NORMAL");
1916 } catch (IllegalStateException e) {
1917 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
1922 public void setRepeat(Command command) {
1923 if (command instanceof StringType) {
1925 ZonePlayerHandler coordinator = getCoordinatorHandler();
1927 switch (command.toString()) {
1929 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
1932 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
1935 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
1938 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
1939 command.toString());
1942 } catch (IllegalStateException e) {
1943 logger.debug("Cannot handle repeat command ({})", e.getMessage());
1948 public void setSubwoofer(Command command) {
1949 setEqualizerBooleanSetting(command, "SubEnabled");
1952 public void setSubwooferGain(Command command) {
1953 setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
1956 public void setSurround(Command command) {
1957 setEqualizerBooleanSetting(command, "SurroundEnabled");
1960 public void setSurroundMusicMode(Command command) {
1961 if (command instanceof StringType) {
1962 setEQ("SurroundMode", command.toString());
1966 public void setSurroundMusicLevel(Command command) {
1967 setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
1968 MAX_SURROUND_LEVEL);
1971 public void setSurroundTvLevel(Command command) {
1972 setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
1973 MAX_SURROUND_LEVEL);
1976 public void setNightMode(Command command) {
1977 setEqualizerBooleanSetting(command, "NightMode");
1980 public void setSpeechEnhancement(Command command) {
1981 setEqualizerBooleanSetting(command, "DialogLevel");
1984 private void setEqualizerBooleanSetting(Command command, String eqType) {
1985 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1986 setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1987 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
1991 private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
1993 if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1994 String newValue = null;
1995 if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1996 int i = Integer.valueOf(currentValue);
1997 newValue = String.valueOf(Math.min(maxValue, i + 1));
1998 } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1999 int i = Integer.valueOf(currentValue);
2000 newValue = String.valueOf(Math.max(minValue, i - 1));
2001 } else if (command instanceof DecimalType) {
2002 newValue = String.valueOf(((DecimalType) command).intValue());
2006 setEQ(eqType, newValue);
2010 private void setEQ(String eqType, String value) {
2012 Map<String, String> inputs = new HashMap<>();
2013 inputs.put("InstanceID", "0");
2014 inputs.put("EQType", eqType);
2015 inputs.put("DesiredValue", value);
2016 Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetEQ", inputs);
2018 for (String variable : result.keySet()) {
2019 this.onValueReceived(variable, result.get(variable), "RenderingControl");
2021 } catch (IllegalStateException e) {
2022 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2026 public @Nullable String getNightMode() {
2027 return stateMap.get("NightMode");
2030 public boolean isNightModeOn() {
2031 return "1".equals(getNightMode());
2034 public @Nullable String getDialogLevel() {
2035 return stateMap.get("DialogLevel");
2038 public boolean isSpeechEnhanced() {
2039 return "1".equals(getDialogLevel());
2042 public @Nullable String getPlayMode() {
2043 return stateMap.get("CurrentPlayMode");
2046 public Boolean isShuffleActive() {
2047 String playMode = getPlayMode();
2048 return (playMode != null && playMode.startsWith("SHUFFLE"));
2051 public String getRepeatMode() {
2052 String mode = "OFF";
2053 String playMode = getPlayMode();
2054 if (playMode != null) {
2061 case "SHUFFLE_REPEAT_ONE":
2065 case "SHUFFLE_NOREPEAT":
2074 protected void updatePlayMode(String playMode) {
2075 Map<String, String> inputs = new HashMap<>();
2076 inputs.put("InstanceID", "0");
2077 inputs.put("NewPlayMode", playMode);
2079 Map<String, String> result = service.invokeAction(this, "AVTransport", "SetPlayMode", inputs);
2081 for (String variable : result.keySet()) {
2082 this.onValueReceived(variable, result.get(variable), "AVTransport");
2087 * Clear all scheduled music from the current queue.
2090 public void removeAllTracksFromQueue() {
2091 Map<String, String> inputs = new HashMap<>();
2092 inputs.put("InstanceID", "0");
2094 Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveAllTracksFromQueue", inputs);
2096 for (String variable : result.keySet()) {
2097 this.onValueReceived(variable, result.get(variable), "AVTransport");
2102 * Play music from the line-in of the given Player referenced by the given UDN or name
2104 * @param udn or name
2106 public void playLineIn(Command command) {
2107 if (command instanceof StringType) {
2109 LineInType lineInType = LineInType.ANY;
2110 String remotePlayerName = command.toString();
2111 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2112 lineInType = LineInType.ANALOG;
2113 remotePlayerName = remotePlayerName.substring(7);
2114 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2115 lineInType = LineInType.DIGITAL;
2116 remotePlayerName = remotePlayerName.substring(8);
2118 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2119 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2121 // check if player has a line-in connected
2122 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2123 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2124 // stop whatever is currently playing
2125 coordinatorHandler.stop();
2128 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2129 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2131 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2134 // take the system off mute
2135 coordinatorHandler.setMute(OnOffType.OFF);
2138 coordinatorHandler.play();
2140 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2142 } catch (IllegalStateException e) {
2143 logger.debug("Cannot play line-in ({})", e.getMessage());
2148 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2149 ZonePlayerHandler handler = coordinatorHandler;
2150 if (handler != null) {
2154 handler = getHandlerByName(getCoordinator());
2155 coordinatorHandler = handler;
2157 } catch (IllegalStateException e) {
2158 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2163 * Returns a list of all zone group members this particular player is member of
2164 * Or empty list if the players is not assigned to any group
2166 * @return a list of Strings containing the UDNs of other group members
2168 protected List<String> getZoneGroupMembers() {
2169 List<String> result = new ArrayList<>();
2171 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2172 if (!zoneGroups.isEmpty()) {
2173 for (SonosZoneGroup zg : zoneGroups) {
2174 if (zg.getMembers().contains(getUDN())) {
2175 result.addAll(zg.getMembers());
2180 // If the group topology was not yet received, return at least the current Sonos zone
2181 result.add(getUDN());
2187 * Returns a list of other zone group members this particular player is member of
2188 * Or empty list if the players is not assigned to any group
2190 * @return a list of Strings containing the UDNs of other group members
2192 protected List<String> getOtherZoneGroupMembers() {
2193 List<String> zoneGroupMembers = getZoneGroupMembers();
2194 zoneGroupMembers.remove(getUDN());
2195 return zoneGroupMembers;
2198 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2199 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2200 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2201 if (thing != null) {
2202 ThingHandler handler = thing.getHandler();
2203 if (handler instanceof ZonePlayerHandler) {
2204 return (ZonePlayerHandler) handler;
2208 for (Thing aThing : localThingRegistry.getAll()) {
2209 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2210 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2211 ThingHandler handler = aThing.getHandler();
2212 if (handler instanceof ZonePlayerHandler) {
2213 return (ZonePlayerHandler) handler;
2217 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2220 public void setMute(Command command) {
2221 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2222 Map<String, String> inputs = new HashMap<>();
2223 inputs.put("Channel", "Master");
2225 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2226 inputs.put("DesiredMute", "True");
2227 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2228 || command.equals(OpenClosedType.CLOSED)) {
2229 inputs.put("DesiredMute", "False");
2232 Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetMute", inputs);
2234 for (String variable : result.keySet()) {
2235 this.onValueReceived(variable, result.get(variable), "RenderingControl");
2240 public List<SonosAlarm> getCurrentAlarmList() {
2241 Map<String, String> result = service.invokeAction(this, "AlarmClock", "ListAlarms", null);
2243 for (String variable : result.keySet()) {
2244 this.onValueReceived(variable, result.get(variable), "AlarmClock");
2247 String alarmList = result.get("CurrentAlarmList");
2248 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2251 public void updateAlarm(SonosAlarm alarm) {
2252 Map<String, String> inputs = new HashMap<>();
2255 inputs.put("ID", Integer.toString(alarm.getId()));
2256 inputs.put("StartLocalTime", alarm.getStartTime());
2257 inputs.put("Duration", alarm.getDuration());
2258 inputs.put("Recurrence", alarm.getRecurrence());
2259 inputs.put("RoomUUID", alarm.getRoomUUID());
2260 inputs.put("ProgramURI", alarm.getProgramURI());
2261 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2262 inputs.put("PlayMode", alarm.getPlayMode());
2263 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2264 if (alarm.getIncludeLinkedZones()) {
2265 inputs.put("IncludeLinkedZones", "1");
2267 inputs.put("IncludeLinkedZones", "0");
2270 if (alarm.getEnabled()) {
2271 inputs.put("Enabled", "1");
2273 inputs.put("Enabled", "0");
2275 } catch (NumberFormatException ex) {
2276 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2279 Map<String, String> result = service.invokeAction(this, "AlarmClock", "UpdateAlarm", inputs);
2281 for (String variable : result.keySet()) {
2282 this.onValueReceived(variable, result.get(variable), "AlarmClock");
2286 public void setAlarm(Command command) {
2287 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2288 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2290 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2291 || command.equals(OpenClosedType.CLOSED)) {
2297 public void setAlarm(boolean alarmSwitch) {
2298 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2300 // find the nearest alarm - take the current time from the Sonos system,
2301 // not the system where we are running
2302 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2303 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2305 String currentLocalTime = getTime();
2306 Date currentDateTime = null;
2308 currentDateTime = fmt.parse(currentLocalTime);
2309 } catch (ParseException e) {
2310 logger.debug("An exception occurred while formatting a date", e);
2313 if (currentDateTime != null) {
2314 Calendar currentDateTimeCalendar = Calendar.getInstance();
2315 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2316 currentDateTimeCalendar.setTime(currentDateTime);
2317 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2318 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2320 SonosAlarm firstAlarm = null;
2322 for (SonosAlarm anAlarm : sonosAlarms) {
2323 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2324 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2327 durationDate = durationFormat.parse(anAlarm.getDuration());
2328 } catch (ParseException e) {
2329 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2333 long duration = durationDate.getTime();
2335 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2336 shortestDuration = duration;
2337 firstAlarm = anAlarm;
2342 if (firstAlarm != null) {
2344 firstAlarm.setEnabled(true);
2346 firstAlarm.setEnabled(false);
2349 updateAlarm(firstAlarm);
2354 public @Nullable String getTime() {
2356 return stateMap.get("CurrentLocalTime");
2359 public @Nullable String getAlarmRunning() {
2360 return stateMap.get("AlarmRunning");
2363 public boolean isAlarmRunning() {
2364 return "1".equals(getAlarmRunning());
2367 public void snoozeAlarm(Command command) {
2368 if (isAlarmRunning() && command instanceof DecimalType) {
2369 int minutes = ((DecimalType) command).intValue();
2371 Map<String, String> inputs = new HashMap<>();
2373 Calendar snoozePeriod = Calendar.getInstance();
2374 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2375 snoozePeriod.setTimeInMillis(0);
2376 snoozePeriod.add(Calendar.MINUTE, minutes);
2377 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2378 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2381 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2382 } catch (NumberFormatException ex) {
2383 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2386 Map<String, String> result = service.invokeAction(this, "AVTransport", "SnoozeAlarm", inputs);
2388 for (String variable : result.keySet()) {
2389 this.onValueReceived(variable, result.get(variable), "AVTransport");
2392 logger.debug("There is no alarm running on {}", getUDN());
2396 public @Nullable String getAnalogLineInConnected() {
2397 return stateMap.get(LINEINCONNECTED);
2400 public boolean isAnalogLineInConnected() {
2401 return "true".equals(getAnalogLineInConnected());
2404 public @Nullable String getOpticalLineInConnected() {
2405 return stateMap.get(TOSLINEINCONNECTED);
2408 public boolean isOpticalLineInConnected() {
2409 return "true".equals(getOpticalLineInConnected());
2412 public void becomeStandAlonePlayer() {
2413 Map<String, String> result = service.invokeAction(this, "AVTransport", "BecomeCoordinatorOfStandaloneGroup",
2416 for (String variable : result.keySet()) {
2417 this.onValueReceived(variable, result.get(variable), "AVTransport");
2421 public void addMember(Command command) {
2422 if (command instanceof StringType) {
2423 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2425 getHandlerByName(command.toString()).setCurrentURI(entry);
2426 } catch (IllegalStateException e) {
2427 logger.debug("Cannot add group member ({})", e.getMessage());
2432 public boolean publicAddress(LineInType lineInType) {
2433 // check if sourcePlayer has a line-in connected
2434 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2435 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2436 // first remove this player from its own group if any
2437 becomeStandAlonePlayer();
2439 // add all other players to this new group
2440 for (SonosZoneGroup group : getZoneGroups()) {
2441 for (String player : group.getMembers()) {
2443 ZonePlayerHandler somePlayer = getHandlerByName(player);
2444 if (somePlayer != this) {
2445 somePlayer.becomeStandAlonePlayer();
2447 addMember(StringType.valueOf(somePlayer.getUDN()));
2449 } catch (IllegalStateException e) {
2450 logger.debug("Cannot add to group ({})", e.getMessage());
2456 ZonePlayerHandler coordinator = getCoordinatorHandler();
2457 // set the URI of the group to the line-in
2458 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2459 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2460 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2462 coordinator.setCurrentURI(entry);
2466 } catch (IllegalStateException e) {
2467 logger.debug("Cannot handle command ({})", e.getMessage());
2471 logger.debug("Line-in of {} is not connected", getUDN());
2477 * Play a given url to music in one of the music libraries.
2480 * in the format of //host/folder/filename.mp3
2482 public void playURI(Command command) {
2483 if (command instanceof StringType) {
2485 String url = command.toString();
2487 ZonePlayerHandler coordinator = getCoordinatorHandler();
2489 // stop whatever is currently playing
2491 coordinator.waitForNotTransportState(STATE_PLAYING);
2493 // clear any tracks which are pending in the queue
2494 coordinator.removeAllTracksFromQueue();
2496 // add the new track we want to play to the queue
2497 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2498 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2499 // default to file based url
2500 url = FILE_URI + url;
2502 coordinator.addURIToQueue(url, "", 0, true);
2504 // set the current playlist to our new queue
2505 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2507 // take the system off mute
2508 coordinator.setMute(OnOffType.OFF);
2512 } catch (IllegalStateException e) {
2513 logger.debug("Cannot play URI ({})", e.getMessage());
2518 private void scheduleNotificationSound(final Command command) {
2519 scheduler.submit(() -> {
2520 synchronized (notificationLock) {
2521 playNotificationSoundURI(command);
2527 * Play a given notification sound
2529 * @param url in the format of //host/folder/filename.mp3
2531 public void playNotificationSoundURI(Command notificationURL) {
2532 if (notificationURL instanceof StringType) {
2534 ZonePlayerHandler coordinator = getCoordinatorHandler();
2536 String currentURI = coordinator.getCurrentURI();
2537 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2538 coordinator.getCurrentURIMetadataAsString());
2540 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2541 || isPlayingRadio(currentURI)) {
2542 handleRadioStream(currentURI, notificationURL, coordinator);
2543 } else if (isPlayingLineIn(currentURI)) {
2544 handleLineIn(currentURI, notificationURL, coordinator);
2545 } else if (isPlayingQueue(currentURI)) {
2546 handleSharedQueue(currentURI, notificationURL, coordinator);
2547 } else if (isPlaylistEmpty(coordinator)) {
2548 handleEmptyQueue(notificationURL, coordinator);
2550 synchronized (notificationLock) {
2551 notificationLock.notify();
2553 } catch (IllegalStateException e) {
2554 logger.debug("Cannot play sound ({})", e.getMessage());
2559 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2560 return coordinator.getQueueSize() == 0;
2563 private boolean isPlayingQueue(@Nullable String currentURI) {
2564 return currentURI != null && currentURI.contains(QUEUE_URI);
2567 private boolean isPlayingStream(@Nullable String currentURI) {
2568 return currentURI != null && currentURI.contains(STREAM_URI);
2571 private boolean isPlayingRadio(@Nullable String currentURI) {
2572 return currentURI != null && currentURI.contains(RADIO_URI);
2575 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2576 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2579 private boolean isPlayingLineIn(@Nullable String currentURI) {
2580 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2583 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2584 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2587 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2588 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2592 * Does a chain of predefined actions when a Notification sound is played by
2593 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2594 * radio streaming is currently loaded
2596 * @param currentStreamURI - the currently loaded stream's URI
2597 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2598 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2600 private void handleRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2601 ZonePlayerHandler coordinator) {
2602 String nextAction = coordinator.getTransportState();
2603 SonosMetaData track = coordinator.getTrackMetadata();
2604 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2606 handleNotificationSound(notificationURL, coordinator);
2607 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2608 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2609 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2610 restoreLastTransportState(coordinator, nextAction);
2615 * Does a chain of predefined actions when a Notification sound is played by
2616 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2617 * line in is currently loaded
2619 * @param currentLineInURI - the currently loaded line-in URI
2620 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2621 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2623 private void handleLineIn(@Nullable String currentLineInURI, Command notificationURL,
2624 ZonePlayerHandler coordinator) {
2625 logger.debug("Handling notification while sound from line-in was being played");
2626 String nextAction = coordinator.getTransportState();
2628 handleNotificationSound(notificationURL, coordinator);
2629 if (currentLineInURI != null) {
2630 logger.debug("Restoring sound from line-in using {}", currentLineInURI);
2631 coordinator.setCurrentURI(currentLineInURI, "");
2632 restoreLastTransportState(coordinator, nextAction);
2637 * Does a chain of predefined actions when a Notification sound is played by
2638 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2639 * shared queue is currently loaded
2641 * @param currentQueueURI - the currently loaded queue URI
2642 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2643 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2645 private void handleSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2646 ZonePlayerHandler coordinator) {
2647 String nextAction = coordinator.getTransportState();
2648 String trackPosition = coordinator.getRefreshedPosition();
2649 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2650 logger.debug("handleSharedQueue: currentQueueURI {} trackPosition {} currentTrackNumber {}", currentQueueURI,
2651 trackPosition, currentTrackNumber);
2653 handleNotificationSound(notificationURL, coordinator);
2654 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2655 if (queueUri.equals(currentQueueURI)) {
2656 coordinator.setPositionTrack(currentTrackNumber);
2657 coordinator.setPosition(trackPosition);
2658 restoreLastTransportState(coordinator, nextAction);
2663 * Handle the execution of the notification sound by sequentially executing the required steps.
2665 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2666 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2668 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator) {
2669 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2670 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2671 if (sourceStoppable) {
2673 coordinator.waitForNotTransportState(STATE_PLAYING);
2674 applyNotificationSoundVolume();
2676 long notificationPosition = coordinator.getQueueSize() + 1;
2677 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2678 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2679 coordinator.setPositionTrack(notificationPosition);
2680 if (!sourceStoppable) {
2682 coordinator.waitForNotTransportState(STATE_PLAYING);
2683 applyNotificationSoundVolume();
2686 coordinator.waitForFinishedNotification();
2687 if (originalVolume != null) {
2688 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2690 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2693 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction) {
2694 if (nextAction != null) {
2695 switch (nextAction) {
2698 coordinator.waitForTransportState(STATE_PLAYING);
2700 case STATE_PAUSED_PLAYBACK:
2701 coordinator.pause();
2708 * Does a chain of predefined actions when a Notification sound is played by
2709 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2710 * empty queue is currently loaded
2712 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2713 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2715 private void handleEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator) {
2716 String originalVolume = coordinator.getVolume();
2717 coordinator.applyNotificationSoundVolume();
2718 coordinator.playURI(notificationURL);
2719 coordinator.waitForFinishedNotification();
2720 coordinator.removeAllTracksFromQueue();
2721 if (originalVolume != null) {
2722 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2727 * Applies the notification sound volume level to the group (if not null)
2729 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2731 private void applyNotificationSoundVolume() {
2732 setNotificationSoundVolume(getNotificationSoundVolume());
2735 private void waitForFinishedNotification() {
2736 waitForTransportState(STATE_PLAYING);
2738 // check Sonos state events to determine the end of the notification sound
2739 String notificationTitle = getCurrentTitle();
2740 long playstart = System.currentTimeMillis();
2741 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2744 String currentTitle = getCurrentTitle();
2745 if ((notificationTitle == null && currentTitle != null)
2746 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2747 || !STATE_PLAYING.equals(getTransportState())) {
2750 } catch (InterruptedException e) {
2751 logger.debug("InterruptedException during playing a notification sound");
2756 private void waitForTransportState(String state) {
2757 if (getTransportState() != null) {
2758 long start = System.currentTimeMillis();
2759 while (!state.equals(getTransportState())) {
2762 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2765 } catch (InterruptedException e) {
2766 logger.debug("InterruptedException during playing a notification sound");
2772 private void waitForNotTransportState(String state) {
2773 if (getTransportState() != null) {
2774 long start = System.currentTimeMillis();
2775 while (state.equals(getTransportState())) {
2778 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2781 } catch (InterruptedException e) {
2782 logger.debug("InterruptedException during playing a notification sound");
2789 * Removes a range of tracks from the queue.
2790 * (<x,y> will remove y songs started by the song number x)
2792 * @param command - must be in the format <startIndex, numberOfSongs>
2794 public void removeRangeOfTracksFromQueue(Command command) {
2795 if (command instanceof StringType) {
2796 Map<String, String> inputs = new HashMap<>();
2797 String[] rangeInputSplit = command.toString().split(",");
2799 // If range input is incorrect, remove the first song by default
2800 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2801 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2803 inputs.put("InstanceID", "0");
2804 inputs.put("StartingIndex", startIndex);
2805 inputs.put("NumberOfTracks", numberOfTracks);
2807 Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveTrackRangeFromQueue", inputs);
2809 for (String variable : result.keySet()) {
2810 this.onValueReceived(variable, result.get(variable), "AVTransport");
2815 public void clearQueue() {
2817 ZonePlayerHandler coordinator = getCoordinatorHandler();
2819 coordinator.removeAllTracksFromQueue();
2820 } catch (IllegalStateException e) {
2821 logger.debug("Cannot clear queue ({})", e.getMessage());
2825 public void playQueue() {
2827 ZonePlayerHandler coordinator = getCoordinatorHandler();
2829 // set the current playlist to our new queue
2830 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2832 // take the system off mute
2833 coordinator.setMute(OnOffType.OFF);
2837 } catch (IllegalStateException e) {
2838 logger.debug("Cannot play queue ({})", e.getMessage());
2842 public void setLed(Command command) {
2843 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2844 Map<String, String> inputs = new HashMap<>();
2846 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2847 inputs.put("DesiredLEDState", "On");
2848 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2849 || command.equals(OpenClosedType.CLOSED)) {
2850 inputs.put("DesiredLEDState", "Off");
2853 Map<String, String> result = service.invokeAction(this, "DeviceProperties", "SetLEDState", inputs);
2854 Map<String, String> result2 = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
2856 result.putAll(result2);
2858 for (String variable : result.keySet()) {
2859 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
2864 public void removeMember(Command command) {
2865 if (command instanceof StringType) {
2867 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2869 oldmemberHandler.becomeStandAlonePlayer();
2870 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2871 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2872 oldmemberHandler.setCurrentURI(entry);
2873 } catch (IllegalStateException e) {
2874 logger.debug("Cannot remove group member ({})", e.getMessage());
2879 public void previous() {
2880 Map<String, String> result = service.invokeAction(this, "AVTransport", "Previous", null);
2882 for (String variable : result.keySet()) {
2883 this.onValueReceived(variable, result.get(variable), "AVTransport");
2887 public void next() {
2888 Map<String, String> result = service.invokeAction(this, "AVTransport", "Next", null);
2890 for (String variable : result.keySet()) {
2891 this.onValueReceived(variable, result.get(variable), "AVTransport");
2895 public void stopPlaying(Command command) {
2896 if (command instanceof OnOffType) {
2898 getCoordinatorHandler().stop();
2899 } catch (IllegalStateException e) {
2900 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2905 public void playRadio(Command command) {
2906 if (command instanceof StringType) {
2907 String station = command.toString();
2908 List<SonosEntry> stations = getFavoriteRadios();
2910 SonosEntry theEntry = null;
2911 // search for the appropriate radio based on its name (title)
2912 for (SonosEntry someStation : stations) {
2913 if (someStation.getTitle().equals(station)) {
2914 theEntry = someStation;
2919 // set the URI of the group coordinator
2920 if (theEntry != null) {
2922 ZonePlayerHandler coordinator = getCoordinatorHandler();
2923 coordinator.setCurrentURI(theEntry);
2925 } catch (IllegalStateException e) {
2926 logger.debug("Cannot play radio ({})", e.getMessage());
2929 logger.debug("Radio station '{}' not found", station);
2934 public void playTuneinStation(Command command) {
2935 if (command instanceof StringType) {
2936 String stationId = command.toString();
2937 List<SonosMusicService> allServices = getAvailableMusicServices();
2939 SonosMusicService tuneinService = null;
2940 // search for the TuneIn music service based on its name
2941 if (allServices != null) {
2942 for (SonosMusicService service : allServices) {
2943 if (service.getName().equals("TuneIn")) {
2944 tuneinService = service;
2950 // set the URI of the group coordinator
2951 if (tuneinService != null) {
2953 ZonePlayerHandler coordinator = getCoordinatorHandler();
2954 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
2955 "object.item.audioItem.audioBroadcast",
2956 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
2957 Integer tuneinServiceType = tuneinService.getType();
2958 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
2959 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
2960 coordinator.setCurrentURI(entry);
2962 } catch (IllegalStateException e) {
2963 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
2966 logger.debug("TuneIn service not found");
2971 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
2972 if (musicServices == null) {
2973 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
2975 String serviceList = result.get("AvailableServiceDescriptorList");
2976 if (serviceList != null) {
2977 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
2978 musicServices = services;
2980 String[] servicesTypes = new String[0];
2981 String serviceTypeList = result.get("AvailableServiceTypeList");
2982 if (serviceTypeList != null) {
2983 // It is a comma separated list of service types (integers) in the same order as the services
2984 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
2986 servicesTypes = serviceTypeList.split(",");
2990 for (SonosMusicService service : services) {
2991 if (!service.getName().equals("TuneIn")) {
2992 // Add the service type integer value from "AvailableServiceTypeList" to each service
2994 if (idx < servicesTypes.length) {
2996 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
2997 service.setType(serviceType);
2998 } catch (NumberFormatException e) {
3003 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3005 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3010 return musicServices;
3014 * This will attempt to match the station string with a entry in the
3015 * favorites list, this supports both single entries and playlists
3017 * @param favorite to match
3018 * @return true if a match was found and played.
3020 public void playFavorite(Command command) {
3021 if (command instanceof StringType) {
3022 String favorite = command.toString();
3023 List<SonosEntry> favorites = getFavorites();
3025 SonosEntry theEntry = null;
3026 // search for the appropriate favorite based on its name (title)
3027 for (SonosEntry entry : favorites) {
3028 if (entry.getTitle().equals(favorite)) {
3034 // set the URI of the group coordinator
3035 if (theEntry != null) {
3037 ZonePlayerHandler coordinator = getCoordinatorHandler();
3040 * If this is a playlist we need to treat it as such
3042 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3043 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3044 coordinator.removeAllTracksFromQueue();
3045 coordinator.addURIToQueue(theEntry);
3046 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3047 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3048 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3050 coordinator.setCurrentURI(theEntry);
3053 } catch (IllegalStateException e) {
3054 logger.debug("Cannot paly favorite ({})", e.getMessage());
3057 logger.debug("Favorite '{}' not found", favorite);
3062 public void playTrack(Command command) {
3063 if (command instanceof DecimalType) {
3065 ZonePlayerHandler coordinator = getCoordinatorHandler();
3067 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3069 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3071 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3072 coordinator.setPositionTrack(trackNumber);
3074 // take the system off mute
3075 coordinator.setMute(OnOffType.OFF);
3079 } catch (IllegalStateException e) {
3080 logger.debug("Cannot play track ({})", e.getMessage());
3085 public void playPlayList(Command command) {
3086 if (command instanceof StringType) {
3087 String playlist = command.toString();
3088 List<SonosEntry> playlists = getPlayLists();
3090 SonosEntry theEntry = null;
3091 // search for the appropriate play list based on its name (title)
3092 for (SonosEntry somePlaylist : playlists) {
3093 if (somePlaylist.getTitle().equals(playlist)) {
3094 theEntry = somePlaylist;
3099 // set the URI of the group coordinator
3100 if (theEntry != null) {
3102 ZonePlayerHandler coordinator = getCoordinatorHandler();
3104 coordinator.addURIToQueue(theEntry);
3106 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3108 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3109 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3112 } catch (IllegalStateException e) {
3113 logger.debug("Cannot play playlist ({})", e.getMessage());
3116 logger.debug("Playlist '{}' not found", playlist);
3121 public void addURIToQueue(SonosEntry newEntry) {
3122 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3125 public @Nullable String getZoneName() {
3126 return stateMap.get("ZoneName");
3129 public @Nullable String getZoneGroupID() {
3130 return stateMap.get("LocalGroupUUID");
3133 public @Nullable String getRunningAlarmProperties() {
3134 return stateMap.get("RunningAlarmProperties");
3137 public @Nullable String getRefreshedRunningAlarmProperties() {
3138 updateRunningAlarmProperties();
3139 return getRunningAlarmProperties();
3142 public @Nullable String getMute() {
3143 return stateMap.get("MuteMaster");
3146 public boolean isMuted() {
3147 return "1".equals(getMute());
3150 public @Nullable String getLed() {
3151 return stateMap.get("CurrentLEDState");
3154 public boolean isLedOn() {
3155 return "On".equals(getLed());
3158 public @Nullable String getCurrentZoneName() {
3159 return stateMap.get("CurrentZoneName");
3162 public @Nullable String getRefreshedCurrentZoneName() {
3163 updateCurrentZoneName();
3164 return getCurrentZoneName();
3168 public void onStatusChanged(boolean status) {
3170 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3171 if (getThing().getStatus() != ThingStatus.ONLINE) {
3172 updateStatus(ThingStatus.ONLINE);
3173 scheduler.execute(this::poll);
3176 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3181 private @Nullable String getModelNameFromDescriptor() {
3182 URL descriptor = service.getDescriptorURL(this);
3183 if (descriptor != null) {
3184 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3185 return sonosModelDescription == null ? null : SonosXMLParser.extractModelName(sonosModelDescription);
3191 private boolean migrateThingType() {
3192 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3193 String modelName = getModelNameFromDescriptor();
3194 if (modelName != null && isSupportedModel(modelName)) {
3195 updateSonosThingType(modelName);
3202 private boolean isSupportedModel(String modelName) {
3203 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3204 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3211 private void updateSonosThingType(String newThingTypeID) {
3212 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3216 * Set the sleeptimer duration
3217 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3218 * Use empty String "" to switch the sleep timer off
3220 public void setSleepTimer(Command command) {
3221 if (command instanceof DecimalType) {
3222 Map<String, String> inputs = new HashMap<>();
3223 inputs.put("InstanceID", "0");
3224 inputs.put("NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue()));
3226 this.service.invokeAction(this, "AVTransport", "ConfigureSleepTimer", inputs);
3230 protected void updateSleepTimerDuration() {
3231 Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRemainingSleepTimerDuration", null);
3232 for (String variable : result.keySet()) {
3233 this.onValueReceived(variable, result.get(variable), "AVTransport");
3237 private String sleepSecondsToTimeStr(long sleepSeconds) {
3238 if (sleepSeconds == 0) {
3240 } else if (sleepSeconds < 68400) {
3241 long remainingSeconds = sleepSeconds;
3242 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3243 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3244 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3245 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3246 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3247 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3249 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3254 private long sleepStrTimeToSeconds(String sleepTime) {
3255 String[] units = sleepTime.split(":");
3256 int hours = Integer.parseInt(units[0]);
3257 int minutes = Integer.parseInt(units[1]);
3258 int seconds = Integer.parseInt(units[2]);
3259 return 3600 * hours + 60 * minutes + seconds;