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 VIRTUAL_LINE_IN_URI = "x-sonos-vli:";
90 private static final String QUEUE_URI = "x-rincon-queue:";
91 private static final String GROUP_URI = "x-rincon:";
92 private static final String STREAM_URI = "x-sonosapi-stream:";
93 private static final String RADIO_URI = "x-sonosapi-radio:";
94 private static final String RADIO_MP3_URI = "x-rincon-mp3radio:";
95 private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
96 private static final String FILE_URI = "x-file-cifs:";
97 private static final String SPDIF = ":spdif";
98 private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
100 private static final String STATE_PLAYING = "PLAYING";
101 private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
102 private static final String STATE_STOPPED = "STOPPED";
104 private static final String LINEINCONNECTED = "LineInConnected";
105 private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
107 private static final String SERVICE_DEVICE_PROPERTIES = "DeviceProperties";
108 private static final String SERVICE_AV_TRANSPORT = "AVTransport";
109 private static final String SERVICE_RENDERING_CONTROL = "RenderingControl";
110 private static final String SERVICE_ZONE_GROUP_TOPOLOGY = "ZoneGroupTopology";
111 private static final String SERVICE_GROUP_MANAGEMENT = "GroupManagement";
112 private static final String SERVICE_AUDIO_IN = "AudioIn";
113 private static final String SERVICE_HT_CONTROL = "HTControl";
114 private static final String SERVICE_CONTENT_DIRECTORY = "ContentDirectory";
115 private static final String SERVICE_ALARM_CLOCK = "AlarmClock";
117 private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList(SERVICE_DEVICE_PROPERTIES,
118 SERVICE_AV_TRANSPORT, SERVICE_ZONE_GROUP_TOPOLOGY, SERVICE_GROUP_MANAGEMENT, SERVICE_RENDERING_CONTROL,
119 SERVICE_AUDIO_IN, SERVICE_HT_CONTROL, SERVICE_CONTENT_DIRECTORY);
120 protected static final int SUBSCRIPTION_DURATION = 1800;
122 private static final String ACTION_GET_ZONE_ATTRIBUTES = "GetZoneAttributes";
123 private static final String ACTION_GET_ZONE_INFO = "GetZoneInfo";
124 private static final String ACTION_GET_LED_STATE = "GetLEDState";
125 private static final String ACTION_SET_LED_STATE = "SetLEDState";
127 private static final String ACTION_GET_POSITION_INFO = "GetPositionInfo";
128 private static final String ACTION_SET_AV_TRANSPORT_URI = "SetAVTransportURI";
129 private static final String ACTION_SEEK = "Seek";
130 private static final String ACTION_PLAY = "Play";
131 private static final String ACTION_STOP = "Stop";
132 private static final String ACTION_PAUSE = "Pause";
133 private static final String ACTION_PREVIOUS = "Previous";
134 private static final String ACTION_NEXT = "Next";
135 private static final String ACTION_ADD_URI_TO_QUEUE = "AddURIToQueue";
136 private static final String ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE = "RemoveTrackRangeFromQueue";
137 private static final String ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE = "RemoveAllTracksFromQueue";
138 private static final String ACTION_SAVE_QUEUE = "SaveQueue";
139 private static final String ACTION_SET_PLAY_MODE = "SetPlayMode";
140 private static final String ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP = "BecomeCoordinatorOfStandaloneGroup";
141 private static final String ACTION_GET_RUNNING_ALARM_PROPERTIES = "GetRunningAlarmProperties";
142 private static final String ACTION_SNOOZE_ALARM = "SnoozeAlarm";
143 private static final String ACTION_GET_REMAINING_SLEEP_TIMER_DURATION = "GetRemainingSleepTimerDuration";
144 private static final String ACTION_CONFIGURE_SLEEP_TIMER = "ConfigureSleepTimer";
146 private static final String ACTION_SET_VOLUME = "SetVolume";
147 private static final String ACTION_SET_MUTE = "SetMute";
148 private static final String ACTION_SET_BASS = "SetBass";
149 private static final String ACTION_SET_TREBLE = "SetTreble";
150 private static final String ACTION_SET_LOUDNESS = "SetLoudness";
151 private static final String ACTION_SET_EQ = "SetEQ";
153 private static final int SOCKET_TIMEOUT = 5000;
155 private static final int TUNEIN_DEFAULT_SERVICE_TYPE = 65031;
157 private static final int MIN_BASS = -10;
158 private static final int MAX_BASS = 10;
159 private static final int MIN_TREBLE = -10;
160 private static final int MAX_TREBLE = 10;
161 private static final int MIN_SUBWOOFER_GAIN = -15;
162 private static final int MAX_SUBWOOFER_GAIN = 15;
163 private static final int MIN_SURROUND_LEVEL = -15;
164 private static final int MAX_SURROUND_LEVEL = 15;
165 private static final int MIN_HEIGHT_LEVEL = -10;
166 private static final int MAX_HEIGHT_LEVEL = 10;
168 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
170 private final ThingRegistry localThingRegistry;
171 private final UpnpIOService service;
172 private final @Nullable String opmlUrl;
173 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
175 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
178 * Intrinsic lock used to synchronize the execution of notification sounds
180 private final Object notificationLock = new Object();
181 private final Object upnpLock = new Object();
182 private final Object stateLock = new Object();
183 private final Object jobLock = new Object();
185 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
187 private @Nullable ScheduledFuture<?> pollingJob;
188 private @Nullable SonosZonePlayerState savedState;
190 private Map<String, Boolean> subscriptionState = new HashMap<>();
193 * Thing handler instance of the coordinator speaker used for control delegation
195 private @Nullable ZonePlayerHandler coordinatorHandler;
197 private @Nullable List<SonosMusicService> musicServices;
199 private enum LineInType {
205 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
206 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
208 this.localThingRegistry = thingRegistry;
209 this.opmlUrl = opmlUrl;
210 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
211 this.service = upnpIOService;
212 this.stateDescriptionProvider = stateDescriptionProvider;
216 public void dispose() {
217 logger.debug("Handler disposed for thing {}", getThing().getUID());
219 ScheduledFuture<?> job = this.pollingJob;
223 this.pollingJob = null;
225 removeSubscription();
226 service.unregisterParticipant(this);
230 public void initialize() {
231 logger.debug("initializing handler for thing {}", getThing().getUID());
233 if (migrateThingType()) {
234 // we change the type, so we might need a different handler -> let's finish
238 configuration = getConfigAs(ZonePlayerConfiguration.class);
239 String udn = configuration.udn;
240 if (udn != null && !udn.isEmpty()) {
241 service.registerParticipant(this);
242 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
244 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
245 "@text/offline.conf-error-missing-udn");
246 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
250 private void poll() {
251 synchronized (jobLock) {
252 if (pollingJob == null) {
256 logger.debug("Polling job");
258 // First check if the Sonos zone is set in the UPnP service registry
259 // If not, set the thing state to OFFLINE and wait for the next poll
260 if (!isUpnpDeviceRegistered()) {
261 logger.debug("UPnP device {} not yet registered", getUDN());
262 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
263 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
264 synchronized (upnpLock) {
265 subscriptionState = new HashMap<>();
270 // Check if the Sonos zone can be joined
271 // If not, set the thing state to OFFLINE and do nothing else
273 if (getThing().getStatus() != ThingStatus.ONLINE) {
279 if (isLinked(ZONENAME)) {
280 updateCurrentZoneName();
285 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
286 if (isLinked(SLEEPTIMER) && isCoordinator()) {
287 updateSleepTimerDuration();
289 } catch (Exception e) {
290 logger.debug("Exception during poll: {}", e.getMessage(), e);
296 public void handleCommand(ChannelUID channelUID, Command command) {
297 if (command == RefreshType.REFRESH) {
298 updateChannel(channelUID.getId());
300 switch (channelUID.getId()) {
307 case NOTIFICATIONSOUND:
308 scheduleNotificationSound(command);
311 stopPlaying(command);
314 setVolumeForGroup(command);
323 setLoudness(command);
326 setSubwoofer(command);
329 setSubwooferGain(command);
332 setSurround(command);
334 case SURROUNDMUSICMODE:
335 setSurroundMusicMode(command);
337 case SURROUNDMUSICLEVEL:
338 setSurroundMusicLevel(command);
340 case SURROUNDTVLEVEL:
341 setSurroundTvLevel(command);
344 setHeightLevel(command);
350 removeMember(command);
353 becomeStandAlonePlayer();
356 publicAddress(LineInType.ANY);
358 case PUBLICANALOGADDRESS:
359 publicAddress(LineInType.ANALOG);
361 case PUBLICDIGITALADDRESS:
362 publicAddress(LineInType.DIGITAL);
367 case TUNEINSTATIONID:
368 playTuneinStation(command);
371 playFavorite(command);
377 snoozeAlarm(command);
380 saveAllPlayerState();
383 restoreAllPlayerState();
392 playPlayList(command);
411 if (command instanceof PlayPauseType) {
412 if (command == PlayPauseType.PLAY) {
413 getCoordinatorHandler().play();
414 } else if (command == PlayPauseType.PAUSE) {
415 getCoordinatorHandler().pause();
418 if (command instanceof NextPreviousType) {
419 if (command == NextPreviousType.NEXT) {
420 getCoordinatorHandler().next();
421 } else if (command == NextPreviousType.PREVIOUS) {
422 getCoordinatorHandler().previous();
425 // Rewind and Fast Forward are currently not implemented by the binding
426 } catch (IllegalStateException e) {
427 logger.debug("Cannot handle control command ({})", e.getMessage());
431 setSleepTimer(command);
440 setNightMode(command);
442 case SPEECHENHANCEMENT:
443 setSpeechEnhancement(command);
451 private void restoreAllPlayerState() {
452 for (Thing aThing : localThingRegistry.getAll()) {
453 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
454 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
455 if (handler != null) {
456 handler.restoreState();
462 private void saveAllPlayerState() {
463 for (Thing aThing : localThingRegistry.getAll()) {
464 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
465 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
466 if (handler != null) {
474 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
475 if (variable == null || value == null || service == null) {
479 if (getThing().getStatus() == ThingStatus.ONLINE) {
480 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
481 new Object[] { variable, value, service, this.getThing().getUID() });
483 String oldValue = this.stateMap.get(variable);
484 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
488 this.stateMap.put(variable, value);
490 // pre-process some variables, eg XML processing
491 if (SERVICE_AV_TRANSPORT.equals(service) && "LastChange".equals(variable)) {
492 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
493 parsedValues.forEach((variable1, value1) -> {
494 // Update the transport state after the update of the media information
495 // to not break the notification mechanism
496 if (!"TransportState".equals(variable1)) {
497 onValueReceived(variable1, value1, service);
499 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
500 // for a compatibility with the result of the action GetMediaInfo
501 if ("AVTransportURI".equals(variable1)) {
502 onValueReceived("CurrentURI", value1, service);
503 } else if ("AVTransportURIMetaData".equals(variable1)) {
504 onValueReceived("CurrentURIMetaData", value1, service);
507 updateMediaInformation();
508 if (parsedValues.get("TransportState") != null) {
509 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
513 if (SERVICE_RENDERING_CONTROL.equals(service) && "LastChange".equals(variable)) {
514 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
515 parsedValues.forEach((variable1, value1) -> {
516 onValueReceived(variable1, value1, service);
520 List<StateOption> options = new ArrayList<>();
522 // update the appropriate channel
524 case "TransportState":
525 updateChannel(STATE);
526 updateChannel(CONTROL);
528 dispatchOnAllGroupMembers(variable, value, service);
530 case "CurrentPlayMode":
531 updateChannel(SHUFFLE);
532 updateChannel(REPEAT);
533 dispatchOnAllGroupMembers(variable, value, service);
535 case "CurrentLEDState":
539 updateState(ZONENAME, new StringType(value));
541 case "CurrentZoneName":
542 updateChannel(ZONENAME);
544 case "ZoneGroupState":
545 updateChannel(COORDINATOR);
546 // Update coordinator after a change is made to the grouping of Sonos players
547 updateGroupCoordinator();
548 updateMediaInformation();
549 // Update state and control channels for the group members with the coordinator values
550 String transportState = getTransportState();
551 if (transportState != null) {
552 dispatchOnAllGroupMembers("TransportState", transportState, SERVICE_AV_TRANSPORT);
554 // Update shuffle and repeat channels for the group members with the coordinator values
555 String playMode = getPlayMode();
556 if (playMode != null) {
557 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, SERVICE_AV_TRANSPORT);
560 case "LocalGroupUUID":
561 updateChannel(ZONEGROUPID);
563 case "GroupCoordinatorIsLocal":
564 updateChannel(LOCALCOORDINATOR);
567 updateChannel(VOLUME);
576 updateChannel(TREBLE);
578 case "LoudnessMaster":
579 updateChannel(LOUDNESS);
583 updateChannel(TREBLE);
584 updateChannel(LOUDNESS);
587 updateChannel(SUBWOOFER);
590 updateChannel(SUBWOOFERGAIN);
592 case "SurroundEnabled":
593 updateChannel(SURROUND);
596 updateChannel(SURROUNDMUSICMODE);
598 case "SurroundLevel":
599 updateChannel(SURROUNDTVLEVEL);
602 updateChannel(CODEC);
604 case "MusicSurroundLevel":
605 updateChannel(SURROUNDMUSICLEVEL);
607 case "HeightChannelLevel":
608 updateChannel(HEIGHTLEVEL);
611 updateChannel(NIGHTMODE);
614 updateChannel(SPEECHENHANCEMENT);
616 case LINEINCONNECTED:
617 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
618 updateChannel(LINEIN);
620 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
621 .contains(getThing().getThingTypeUID())) {
622 updateChannel(ANALOGLINEIN);
625 case TOSLINEINCONNECTED:
626 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
627 updateChannel(LINEIN);
629 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
630 .contains(getThing().getThingTypeUID())) {
631 updateChannel(DIGITALLINEIN);
635 updateChannel(ALARMRUNNING);
636 updateRunningAlarmProperties();
638 case "RunningAlarmProperties":
639 updateChannel(ALARMPROPERTIES);
641 case "CurrentURIFormatted":
642 updateChannel(CURRENTTRACK);
645 updateChannel(CURRENTTITLE);
647 case "CurrentArtist":
648 updateChannel(CURRENTARTIST);
651 updateChannel(CURRENTALBUM);
654 updateChannel(CURRENTTRANSPORTURI);
656 case "CurrentTrackURI":
657 updateChannel(CURRENTTRACKURI);
659 case "CurrentAlbumArtURI":
660 updateChannel(CURRENTALBUMARTURL);
662 case "CurrentSleepTimerGeneration":
663 if ("0".equals(value)) {
664 updateState(SLEEPTIMER, new DecimalType(0));
667 case "SleepTimerGeneration":
668 if ("0".equals(value)) {
669 updateState(SLEEPTIMER, new DecimalType(0));
671 updateSleepTimerDuration();
674 case "RemainingSleepTimerDuration":
675 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
677 case "CurrentTuneInStationId":
678 updateChannel(TUNEINSTATIONID);
680 case "SavedQueuesUpdateID": // service ContentDirectoy
681 for (SonosEntry entry : getPlayLists()) {
682 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
684 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
686 case "FavoritesUpdateID": // service ContentDirectoy
687 for (SonosEntry entry : getFavorites()) {
688 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
690 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
692 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
693 // Due to a bug in the data type definition of this state variable, it is not set.
694 // As a workaround, we check the state variable named ContainerUpdateIDs.
695 case "ContainerUpdateIDs": // service ContentDirectoy
696 if (value.startsWith("R:0,") || stateDescriptionProvider
697 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
698 for (SonosEntry entry : getFavoriteRadios()) {
699 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
701 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
705 updateChannel(BATTERYCHARGING);
706 updateChannel(BATTERYLEVEL);
709 updateChannel(MICROPHONE);
717 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
718 if (isCoordinator()) {
719 for (String member : getOtherZoneGroupMembers()) {
721 ZonePlayerHandler memberHandler = getHandlerByName(member);
722 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
723 memberHandler.onValueReceived(variable, value, service);
725 } catch (IllegalStateException e) {
726 logger.debug("Cannot update channel for group member ({})", e.getMessage());
732 private @Nullable String getAlbumArtUrl() {
734 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
735 if (albumArtURI != null) {
736 if (albumArtURI.startsWith("http")) {
738 } else if (albumArtURI.startsWith("/")) {
740 URL serviceDescrUrl = service.getDescriptorURL(this);
741 if (serviceDescrUrl != null) {
742 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
743 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
745 } catch (MalformedURLException e) {
746 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
753 protected void updateChannel(String channelId) {
754 if (!isLinked(channelId)) {
760 State newState = UnDefType.UNDEF;
764 value = getTransportState();
766 newState = new StringType(value);
770 value = getTransportState();
771 if (STATE_PLAYING.equals(value)) {
772 newState = PlayPauseType.PLAY;
773 } else if (STATE_STOPPED.equals(value)) {
774 newState = PlayPauseType.PAUSE;
775 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
776 newState = PlayPauseType.PAUSE;
780 value = getTransportState();
782 newState = OnOffType.from(STATE_STOPPED.equals(value));
786 if (getPlayMode() != null) {
787 newState = OnOffType.from(isShuffleActive());
791 if (getPlayMode() != null) {
792 newState = new StringType(getRepeatMode());
798 newState = OnOffType.from(value);
802 value = getCurrentZoneName();
804 newState = new StringType(value);
808 value = getZoneGroupID();
810 newState = new StringType(value);
814 newState = new StringType(getCoordinator());
816 case LOCALCOORDINATOR:
817 if (getGroupCoordinatorIsLocal() != null) {
818 newState = OnOffType.from(isGroupCoordinator());
824 newState = new PercentType(value);
829 if (value != null && !isOutputLevelFixed()) {
830 newState = new DecimalType(value);
835 if (value != null && !isOutputLevelFixed()) {
836 newState = new DecimalType(value);
840 value = getLoudness();
841 if (value != null && !isOutputLevelFixed()) {
842 newState = OnOffType.from(value);
848 newState = OnOffType.from(value);
852 value = getSubwooferEnabled();
854 newState = OnOffType.from(value);
858 value = getSubwooferGain();
860 newState = new DecimalType(value);
864 value = getSurroundEnabled();
866 newState = OnOffType.from(value);
869 case SURROUNDMUSICMODE:
870 value = getSurroundMusicMode();
872 newState = new StringType(value);
875 case SURROUNDMUSICLEVEL:
876 value = getSurroundMusicLevel();
878 newState = new DecimalType(value);
881 case SURROUNDTVLEVEL:
882 value = getSurroundTvLevel();
884 newState = new DecimalType(value);
890 newState = new StringType(value);
894 value = getHeightLevel();
896 newState = new DecimalType(value);
900 value = getNightMode();
902 newState = OnOffType.from(value);
905 case SPEECHENHANCEMENT:
906 value = getDialogLevel();
908 newState = OnOffType.from(value);
912 if (getAnalogLineInConnected() != null) {
913 newState = OnOffType.from(isAnalogLineInConnected());
914 } else if (getOpticalLineInConnected() != null) {
915 newState = OnOffType.from(isOpticalLineInConnected());
919 if (getAnalogLineInConnected() != null) {
920 newState = OnOffType.from(isAnalogLineInConnected());
924 if (getOpticalLineInConnected() != null) {
925 newState = OnOffType.from(isOpticalLineInConnected());
929 if (getAlarmRunning() != null) {
930 newState = OnOffType.from(isAlarmRunning());
933 case ALARMPROPERTIES:
934 value = getRunningAlarmProperties();
936 newState = new StringType(value);
940 value = stateMap.get("CurrentURIFormatted");
942 newState = new StringType(value);
946 value = getCurrentTitle();
948 newState = new StringType(value);
952 value = getCurrentArtist();
954 newState = new StringType(value);
958 value = getCurrentAlbum();
960 newState = new StringType(value);
963 case CURRENTALBUMART:
965 updateAlbumArtChannel(false);
967 case CURRENTALBUMARTURL:
968 url = getAlbumArtUrl();
970 newState = new StringType(url);
973 case CURRENTTRANSPORTURI:
974 value = getCurrentURI();
976 newState = new StringType(value);
979 case CURRENTTRACKURI:
980 value = stateMap.get("CurrentTrackURI");
982 newState = new StringType(value);
985 case TUNEINSTATIONID:
986 value = stateMap.get("CurrentTuneInStationId");
988 newState = new StringType(value);
991 case BATTERYCHARGING:
992 value = extractInfoFromMoreInfo("BattChg");
994 newState = OnOffType.from("CHARGING".equalsIgnoreCase(value));
998 value = extractInfoFromMoreInfo("BattPct");
1000 newState = new DecimalType(value);
1004 value = getMicEnabled();
1005 if (value != null) {
1006 newState = OnOffType.from(value);
1013 if (newState != null) {
1014 updateState(channelId, newState);
1018 private void updateAlbumArtChannel(boolean allGroup) {
1019 String url = getAlbumArtUrl();
1021 // We download the cover art in a different thread to not delay the other operations
1022 scheduler.submit(() -> {
1023 RawType image = HttpUtil.downloadImage(url, true, 500000);
1024 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
1027 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
1031 private void updateChannel(String channeldD, State state, boolean allGroup) {
1033 for (String member : getZoneGroupMembers()) {
1035 ZonePlayerHandler memberHandler = getHandlerByName(member);
1036 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
1037 && memberHandler.isLinked(channeldD)) {
1038 memberHandler.updateState(channeldD, state);
1040 } catch (IllegalStateException e) {
1041 logger.debug("Cannot update channel for group member ({})", e.getMessage());
1044 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
1045 updateState(channeldD, state);
1050 * CurrentURI will not change, but will trigger change of CurrentURIFormated
1051 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
1053 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
1054 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
1057 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
1058 return oldValue != null ? !oldValue.equals(value) : value != null;
1062 * Similar to the AVTransport eventing, the Queue events its state variables
1063 * as sub values within a synthesized LastChange state variable.
1065 private boolean isQueueEvent(String variable) {
1066 return "LastChange".equals(variable);
1069 private void updateGroupCoordinator() {
1071 coordinatorHandler = getHandlerByName(getCoordinator());
1072 } catch (IllegalStateException e) {
1073 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
1074 coordinatorHandler = null;
1078 private boolean isUpnpDeviceRegistered() {
1079 return service.isRegistered(this);
1082 private void addSubscription() {
1083 synchronized (upnpLock) {
1084 // Set up GENA Subscriptions
1085 if (service.isRegistered(this)) {
1086 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1087 Boolean state = subscriptionState.get(subscription);
1088 if (state == null || !state) {
1089 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
1090 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
1091 subscriptionState.put(subscription, true);
1098 private void removeSubscription() {
1099 synchronized (upnpLock) {
1100 // Set up GENA Subscriptions
1101 if (service.isRegistered(this)) {
1102 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1103 Boolean state = subscriptionState.get(subscription);
1104 if (state != null && state) {
1105 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
1106 service.removeSubscription(this, subscription);
1110 subscriptionState = new HashMap<>();
1115 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
1116 if (service == null) {
1119 synchronized (upnpLock) {
1120 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
1121 subscriptionState.put(service, succeeded);
1125 private Map<String, String> executeAction(String serviceId, String actionId, @Nullable Map<String, String> inputs) {
1126 Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
1127 result.forEach((variable, value) -> {
1128 this.onValueReceived(variable, value, serviceId);
1133 private void updatePlayerState() {
1134 if (!updateZoneInfo()) {
1135 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
1136 logger.debug("Sonos player {} is not available in local network", getUDN());
1137 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1138 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
1139 synchronized (upnpLock) {
1140 subscriptionState = new HashMap<>();
1143 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
1144 logger.debug("Sonos player {} has been found in local network", getUDN());
1145 updateStatus(ThingStatus.ONLINE);
1149 protected void updateCurrentZoneName() {
1150 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_ATTRIBUTES, null);
1153 protected void updateLed() {
1154 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
1157 protected void updateTime() {
1158 executeAction(SERVICE_ALARM_CLOCK, "GetTimeNow", null);
1161 protected void updatePosition() {
1162 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_POSITION_INFO, null);
1165 protected void updateRunningAlarmProperties() {
1166 Map<String, String> result = service.invokeAction(this, SERVICE_AV_TRANSPORT,
1167 ACTION_GET_RUNNING_ALARM_PROPERTIES, null);
1169 String alarmID = result.get("AlarmID");
1170 String loggedStartTime = result.get("LoggedStartTime");
1171 String newStringValue = null;
1172 if (alarmID != null && loggedStartTime != null) {
1173 newStringValue = alarmID + " - " + loggedStartTime;
1175 newStringValue = "No running alarm";
1177 result.put("RunningAlarmProperties", newStringValue);
1179 result.forEach((variable, value) -> {
1180 this.onValueReceived(variable, value, SERVICE_AV_TRANSPORT);
1184 protected boolean updateZoneInfo() {
1185 Map<String, String> result = executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_INFO, null);
1187 Map<String, String> properties = editProperties();
1188 String value = stateMap.get("HardwareVersion");
1189 if (value != null && !value.isEmpty()) {
1190 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1192 value = stateMap.get("DisplaySoftwareVersion");
1193 if (value != null && !value.isEmpty()) {
1194 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1196 value = stateMap.get("SerialNumber");
1197 if (value != null && !value.isEmpty()) {
1198 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1200 value = stateMap.get("MACAddress");
1201 if (value != null && !value.isEmpty()) {
1202 properties.put(MAC_ADDRESS, value);
1204 value = stateMap.get("IPAddress");
1205 if (value != null && !value.isEmpty()) {
1206 properties.put(IP_ADDRESS, value);
1208 updateProperties(properties);
1210 return !result.isEmpty();
1213 public String getCoordinator() {
1214 for (SonosZoneGroup zg : getZoneGroups()) {
1215 if (zg.getMembers().contains(getUDN())) {
1216 return zg.getCoordinator();
1222 public boolean isCoordinator() {
1223 return getUDN().equals(getCoordinator());
1226 protected void updateMediaInformation() {
1227 String currentURI = getCurrentURI();
1228 SonosMetaData currentTrack = getTrackMetadata();
1229 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1231 String artist = null;
1232 String album = null;
1233 String title = null;
1234 String resultString = null;
1235 String stationID = null;
1236 boolean needsUpdating = false;
1238 // if currentURI == null, we do nothing
1239 if (currentURI != null) {
1240 if (currentURI.isEmpty()) {
1242 needsUpdating = true;
1245 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1246 // The Sonos is a slave member of a group
1247 // The media information will be updated by the coordinator
1248 // Notification of group change occurs later, so we just check the URI
1250 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1251 // Radio stream (tune-in)
1252 boolean opmlUrlSucceeded = false;
1253 stationID = extractStationId(currentURI);
1254 String url = opmlUrl;
1256 String mac = getMACAddress();
1257 if (stationID != null && !stationID.isEmpty() && mac != null && !mac.isEmpty()) {
1258 url = url.replace("%id", stationID);
1259 url = url.replace("%serial", mac);
1261 String response = null;
1263 response = HttpUtil.executeUrl("GET", url, SOCKET_TIMEOUT);
1264 } catch (IOException e) {
1265 logger.debug("Request to device failed", e);
1268 if (response != null) {
1269 List<String> fields = SonosXMLParser.getRadioTimeFromXML(response);
1271 if (!fields.isEmpty()) {
1272 opmlUrlSucceeded = true;
1275 for (String field : fields) {
1276 if (resultString.isEmpty()) {
1277 // radio name should be first field
1280 resultString += " - ";
1282 resultString += field;
1285 needsUpdating = true;
1290 if (!opmlUrlSucceeded) {
1291 if (currentUriMetaData != null) {
1292 title = currentUriMetaData.getTitle();
1293 if (currentTrack == null || currentTrack.getStreamContent().isEmpty()) {
1294 resultString = title;
1296 resultString = title + " - " + currentTrack.getStreamContent();
1298 needsUpdating = true;
1303 else if (isPlayingLineIn(currentURI)) {
1304 if (currentTrack != null) {
1305 title = currentTrack.getTitle();
1306 resultString = title;
1307 needsUpdating = true;
1311 else if (isPlayingRadio(currentURI)
1312 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1313 // isPlayingRadio(currentURI) is true for Google Play Music radio or Apple Music radio
1314 if (currentTrack != null) {
1315 artist = !currentTrack.getAlbumArtist().isEmpty() ? currentTrack.getAlbumArtist()
1316 : currentTrack.getCreator();
1317 album = currentTrack.getAlbum();
1318 title = currentTrack.getTitle();
1319 resultString = artist + " - " + album + " - " + title;
1320 needsUpdating = true;
1325 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1326 ? currentTrack.getAlbumArtUri()
1329 ZonePlayerHandler handlerForImageUpdate = null;
1330 for (String member : getZoneGroupMembers()) {
1332 ZonePlayerHandler memberHandler = getHandlerByName(member);
1333 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1334 if (memberHandler.isLinked(CURRENTALBUMART)
1335 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1336 handlerForImageUpdate = memberHandler;
1338 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1339 SERVICE_AV_TRANSPORT);
1340 if (needsUpdating) {
1341 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "",
1342 SERVICE_AV_TRANSPORT);
1343 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "",
1344 SERVICE_AV_TRANSPORT);
1345 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "",
1346 SERVICE_AV_TRANSPORT);
1347 memberHandler.onValueReceived("CurrentURIFormatted", (resultString != null) ? resultString : "",
1348 SERVICE_AV_TRANSPORT);
1349 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, SERVICE_AV_TRANSPORT);
1352 } catch (IllegalStateException e) {
1353 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1356 if (needsUpdating && handlerForImageUpdate != null) {
1357 handlerForImageUpdate.updateAlbumArtChannel(true);
1361 private @Nullable String extractStationId(String uri) {
1362 String stationID = null;
1363 if (isPlayingStream(uri)) {
1364 stationID = substringBetween(uri, ":s", "?sid");
1365 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1366 stationID = substringBetween(uri, "sid=s", "&");
1371 private @Nullable String substringBetween(String str, String open, String close) {
1372 String result = null;
1373 int idx1 = str.indexOf(open);
1375 idx1 += open.length();
1376 int idx2 = str.indexOf(close, idx1);
1378 result = str.substring(idx1, idx2);
1384 public @Nullable String getGroupCoordinatorIsLocal() {
1385 return stateMap.get("GroupCoordinatorIsLocal");
1388 public boolean isGroupCoordinator() {
1389 return "true".equals(getGroupCoordinatorIsLocal());
1393 public String getUDN() {
1394 String udn = configuration.udn;
1395 return udn != null && !udn.isEmpty() ? udn : "undefined";
1398 public @Nullable String getCurrentURI() {
1399 return stateMap.get("CurrentURI");
1402 public @Nullable String getCurrentURIMetadataAsString() {
1403 return stateMap.get("CurrentURIMetaData");
1406 public @Nullable SonosMetaData getCurrentURIMetadata() {
1407 String metaData = getCurrentURIMetadataAsString();
1408 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1411 public @Nullable SonosMetaData getTrackMetadata() {
1412 String metaData = stateMap.get("CurrentTrackMetaData");
1413 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1416 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1417 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1418 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1421 public @Nullable String getMACAddress() {
1422 String mac = stateMap.get("MACAddress");
1423 if (mac == null || mac.isEmpty()) {
1426 return stateMap.get("MACAddress");
1429 public @Nullable String getRefreshedPosition() {
1431 return stateMap.get("RelTime");
1434 public long getRefreshedCurrenTrackNr() {
1436 String value = stateMap.get("Track");
1437 if (value != null) {
1438 return Long.valueOf(value);
1444 public @Nullable String getVolume() {
1445 return stateMap.get("VolumeMaster");
1448 public boolean isOutputLevelFixed() {
1449 return "1".equals(stateMap.get("OutputFixed"));
1452 public @Nullable String getBass() {
1453 return stateMap.get("Bass");
1456 public @Nullable String getTreble() {
1457 return stateMap.get("Treble");
1460 public @Nullable String getLoudness() {
1461 return stateMap.get("LoudnessMaster");
1464 public @Nullable String getSurroundEnabled() {
1465 return stateMap.get("SurroundEnabled");
1468 public @Nullable String getSurroundMusicMode() {
1469 return stateMap.get("SurroundMode");
1472 public @Nullable String getSurroundTvLevel() {
1473 return stateMap.get("SurroundLevel");
1476 public @Nullable String getSurroundMusicLevel() {
1477 return stateMap.get("MusicSurroundLevel");
1480 public @Nullable String getCodec() {
1481 String codec = stateMap.get("HTAudioIn");
1482 if (codec != null) {
1497 codec = "dolbyAtmos";
1515 codec = "Unknown - " + codec;
1521 public @Nullable String getSubwooferEnabled() {
1522 return stateMap.get("SubEnabled");
1525 public @Nullable String getSubwooferGain() {
1526 return stateMap.get("SubGain");
1529 public @Nullable String getHeightLevel() {
1530 return stateMap.get("HeightChannelLevel");
1533 public @Nullable String getTransportState() {
1534 return stateMap.get("TransportState");
1537 public @Nullable String getCurrentTitle() {
1538 return stateMap.get("CurrentTitle");
1541 public @Nullable String getCurrentArtist() {
1542 return stateMap.get("CurrentArtist");
1545 public @Nullable String getCurrentAlbum() {
1546 return stateMap.get("CurrentAlbum");
1549 public List<SonosEntry> getArtists(String filter) {
1550 return getEntries("A:", filter);
1553 public List<SonosEntry> getArtists() {
1554 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1557 public List<SonosEntry> getAlbums(String filter) {
1558 return getEntries("A:ALBUM", filter);
1561 public List<SonosEntry> getAlbums() {
1562 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1565 public List<SonosEntry> getTracks(String filter) {
1566 return getEntries("A:TRACKS", filter);
1569 public List<SonosEntry> getTracks() {
1570 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1573 public List<SonosEntry> getQueue(String filter) {
1574 return getEntries("Q:0", filter);
1577 public List<SonosEntry> getQueue() {
1578 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1581 public long getQueueSize() {
1582 return getNbEntries("Q:0");
1585 public List<SonosEntry> getPlayLists(String filter) {
1586 return getEntries("SQ:", filter);
1589 public List<SonosEntry> getPlayLists() {
1590 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1593 public List<SonosEntry> getFavoriteRadios(String filter) {
1594 return getEntries("R:0/0", filter);
1597 public List<SonosEntry> getFavoriteRadios() {
1598 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1602 * Searches for entries in the 'favorites' list on a sonos account
1606 public List<SonosEntry> getFavorites() {
1607 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1610 protected List<SonosEntry> getEntries(String type, String filter) {
1613 Map<String, String> inputs = new HashMap<>();
1614 inputs.put("ObjectID", type);
1615 inputs.put("BrowseFlag", "BrowseDirectChildren");
1616 inputs.put("Filter", filter);
1617 inputs.put("StartingIndex", Long.toString(startAt));
1618 inputs.put("RequestedCount", Integer.toString(200));
1619 inputs.put("SortCriteria", "");
1621 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1623 String initialResult = result.get("Result");
1624 if (initialResult == null) {
1625 return Collections.emptyList();
1628 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1629 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1631 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1632 startAt = startAt + initialNumberReturned;
1634 while (startAt < totalMatches) {
1635 inputs.put("StartingIndex", Long.toString(startAt));
1636 result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1638 // Execute this action synchronously
1639 String nextResult = result.get("Result");
1640 if (nextResult == null) {
1644 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1646 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1648 startAt = startAt + numberReturned;
1654 protected long getNbEntries(String type) {
1655 Map<String, String> inputs = new HashMap<>();
1656 inputs.put("ObjectID", type);
1657 inputs.put("BrowseFlag", "BrowseDirectChildren");
1658 inputs.put("Filter", "dc:title");
1659 inputs.put("StartingIndex", "0");
1660 inputs.put("RequestedCount", "1");
1661 inputs.put("SortCriteria", "");
1663 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1665 return getResultEntry(result, "TotalMatches", type, "dc:title");
1669 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1671 * @param resultInput - the map to be examined for the requestedKey
1672 * @param requestedKey - the key to be sought in the resultInput map
1673 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1674 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1676 * @return 0 as long or the value corresponding to the requiredKey if found
1678 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1679 String entriesFilter) {
1682 if (resultInput.isEmpty()) {
1687 String resultString = resultInput.get(requestedKey);
1688 if (resultString == null) {
1689 throw new NumberFormatException("Requested key is null.");
1691 result = Long.valueOf(resultString);
1692 } catch (NumberFormatException ex) {
1693 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1694 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1701 * Save the state (track, position etc) of the Sonos Zone player.
1703 * @return true if no error occurred.
1705 protected void saveState() {
1706 synchronized (stateLock) {
1707 savedState = new SonosZonePlayerState();
1708 String currentURI = getCurrentURI();
1710 savedState.transportState = getTransportState();
1711 savedState.volume = getVolume();
1713 if (currentURI != null) {
1714 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
1715 || isPlayingRadio(currentURI)) {
1716 // we are streaming music, like tune-in radio or Google Play Music radio
1717 SonosMetaData track = getTrackMetadata();
1718 SonosMetaData current = getCurrentURIMetadata();
1719 if (track != null && current != null) {
1720 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1721 current.getUpnpClass(), currentURI);
1723 } else if (currentURI.contains(GROUP_URI)) {
1724 // we are a slave to some coordinator
1725 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1726 } else if (isPlayingLineIn(currentURI)) {
1727 // we are streaming from the Line In connection
1728 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1729 } else if (isPlayingQueue(currentURI)) {
1730 // we are playing something that sits in the queue
1731 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1732 if (queued != null) {
1733 savedState.track = getRefreshedCurrenTrackNr();
1735 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1736 // we are playing a real 'saved' playlist
1737 List<SonosEntry> playLists = getPlayLists();
1738 for (SonosEntry someList : playLists) {
1739 if (someList.getTitle().equals(queued.getTitle())) {
1740 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1741 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1746 } else if (queued.getUpnpClass().contains("object.container")) {
1747 // we are playing some other sort of
1748 // 'container' - we will save that to a
1749 // playlist for our convenience
1750 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1752 // save the playlist
1753 String existingList = "";
1754 List<SonosEntry> playLists = getPlayLists();
1755 for (SonosEntry someList : playLists) {
1756 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1757 existingList = someList.getId();
1762 saveQueue(TITLE_PREFIX + getUDN(), existingList);
1764 // get all the playlists and a ref to our
1766 playLists = getPlayLists();
1767 for (SonosEntry someList : playLists) {
1768 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1769 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1770 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1777 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1781 savedState.relTime = getRefreshedPosition();
1783 savedState.entry = null;
1789 * Restore the state (track, position etc) of the Sonos Zone player.
1791 * @return true if no error occurred.
1793 protected void restoreState() {
1794 synchronized (stateLock) {
1795 SonosZonePlayerState state = savedState;
1796 if (state != null) {
1797 // put settings back
1798 String volume = state.volume;
1799 if (volume != null) {
1800 setVolume(DecimalType.valueOf(volume));
1803 if (isCoordinator()) {
1804 SonosEntry entry = state.entry;
1805 if (entry != null) {
1806 // check if we have a playlist to deal with
1807 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1808 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1809 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1810 setCurrentURI(entry);
1811 setPositionTrack(state.track);
1813 setCurrentURI(entry);
1814 setPosition(state.relTime);
1818 String transportState = state.transportState;
1819 if (STATE_PLAYING.equals(transportState)) {
1821 } else if (STATE_STOPPED.equals(transportState)) {
1823 } else if (STATE_PAUSED_PLAYBACK.equals(transportState)) {
1831 public void saveQueue(String name, String queueID) {
1832 executeAction(SERVICE_AV_TRANSPORT, ACTION_SAVE_QUEUE, Map.of("Title", name, "ObjectID", queueID));
1835 public void setVolume(Command command) {
1836 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1837 || command instanceof PercentType) {
1838 String newValue = null;
1839 String currentVolume = getVolume();
1840 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1841 int i = Integer.valueOf(currentVolume);
1842 newValue = String.valueOf(Math.min(100, i + 1));
1843 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1844 int i = Integer.valueOf(currentVolume);
1845 newValue = String.valueOf(Math.max(0, i - 1));
1846 } else if (command == OnOffType.ON) {
1848 } else if (command == OnOffType.OFF) {
1850 } else if (command instanceof DecimalType) {
1851 newValue = String.valueOf(((DecimalType) command).intValue());
1855 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_VOLUME,
1856 Map.of("Channel", "Master", "DesiredVolume", newValue));
1861 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1862 * AdHoc groups handles the volume specifically for each player.
1863 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1865 public void setVolumeForGroup(Command command) {
1866 if (isAdHocGroup() || isStandalonePlayer()) {
1870 getCoordinatorHandler().setVolume(command);
1871 } catch (IllegalStateException e) {
1872 logger.debug("Cannot set group volume ({})", e.getMessage());
1877 public void setBass(Command command) {
1878 if (!isOutputLevelFixed()) {
1879 String newValue = getNewNumericValue(command, getBass(), MIN_BASS, MAX_BASS);
1880 if (newValue != null) {
1881 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_BASS,
1882 Map.of("InstanceID", "0", "DesiredBass", newValue));
1887 public void setTreble(Command command) {
1888 if (!isOutputLevelFixed()) {
1889 String newValue = getNewNumericValue(command, getTreble(), MIN_TREBLE, MAX_TREBLE);
1890 if (newValue != null) {
1891 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_TREBLE,
1892 Map.of("InstanceID", "0", "DesiredTreble", newValue));
1897 private @Nullable String getNewNumericValue(Command command, @Nullable String currentValue, int minValue,
1899 String newValue = null;
1900 if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1901 if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1902 int i = Integer.valueOf(currentValue);
1903 newValue = String.valueOf(Math.min(maxValue, i + 1));
1904 } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1905 int i = Integer.valueOf(currentValue);
1906 newValue = String.valueOf(Math.max(minValue, i - 1));
1907 } else if (command instanceof DecimalType) {
1908 newValue = String.valueOf(((DecimalType) command).intValue());
1914 public void setLoudness(Command command) {
1915 if (!isOutputLevelFixed() && (command instanceof OnOffType || command instanceof OpenClosedType
1916 || command instanceof UpDownType)) {
1917 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1918 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
1919 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_LOUDNESS,
1920 Map.of("InstanceID", "0", "Channel", "Master", "DesiredLoudness", value));
1925 * Checks if the player receiving the command is part of a group that
1926 * consists of randomly added players or contains bonded players
1930 private boolean isAdHocGroup() {
1931 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1932 if (currentZoneGroup != null) {
1933 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1935 for (String zoneName : zoneGroupMemberNames) {
1936 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1937 // At least one "ZoneName" differs so we have an AdHoc group
1946 * Checks if the player receiving the command is a standalone player
1950 private boolean isStandalonePlayer() {
1951 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1952 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1955 private Collection<SonosZoneGroup> getZoneGroups() {
1956 String zoneGroupState = stateMap.get("ZoneGroupState");
1957 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1961 * Returns the current zone group
1962 * (of which the player receiving the command is part)
1964 * @return {@link SonosZoneGroup}
1966 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1967 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1968 if (zoneGroup.getMembers().contains(getUDN())) {
1972 logger.debug("Could not fetch Sonos group state information");
1977 * Sets the volume level for a notification sound
1979 * @param notificationSoundVolume
1981 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1982 if (notificationSoundVolume != null) {
1983 setVolumeForGroup(notificationSoundVolume);
1988 * Gets the volume level for a notification sound
1990 public @Nullable PercentType getNotificationSoundVolume() {
1991 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1992 if (notificationSoundVolume == null) {
1993 // if no value is set we use the current volume instead
1994 String volume = getVolume();
1995 return volume != null ? new PercentType(volume) : null;
1997 return new PercentType(notificationSoundVolume);
2000 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
2001 Map<String, String> inputs = new HashMap<>();
2004 inputs.put("InstanceID", "0");
2005 inputs.put("EnqueuedURI", URI);
2006 inputs.put("EnqueuedURIMetaData", meta);
2007 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
2008 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
2009 } catch (NumberFormatException ex) {
2010 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2013 executeAction(SERVICE_AV_TRANSPORT, ACTION_ADD_URI_TO_QUEUE, inputs);
2016 public void setCurrentURI(SonosEntry newEntry) {
2017 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
2020 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
2021 if (URI != null && URIMetaData != null) {
2022 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
2023 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_AV_TRANSPORT_URI,
2024 Map.of("InstanceID", "0", "CurrentURI", URI, "CurrentURIMetaData", URIMetaData));
2028 public void setPosition(@Nullable String relTime) {
2029 seek("REL_TIME", relTime);
2032 public void setPositionTrack(long tracknr) {
2033 seek("TRACK_NR", Long.toString(tracknr));
2036 public void setPositionTrack(String tracknr) {
2037 seek("TRACK_NR", tracknr);
2040 protected void seek(String unit, @Nullable String target) {
2041 if (target != null) {
2042 executeAction(SERVICE_AV_TRANSPORT, ACTION_SEEK, Map.of("InstanceID", "0", "Unit", unit, "Target", target));
2046 public void play() {
2047 executeAction(SERVICE_AV_TRANSPORT, ACTION_PLAY, Map.of("Speed", "1"));
2050 public void stop() {
2051 executeAction(SERVICE_AV_TRANSPORT, ACTION_STOP, null);
2054 public void pause() {
2055 executeAction(SERVICE_AV_TRANSPORT, ACTION_PAUSE, null);
2058 public void setShuffle(Command command) {
2059 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2061 ZonePlayerHandler coordinator = getCoordinatorHandler();
2063 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2064 || command.equals(OpenClosedType.OPEN)) {
2065 switch (coordinator.getRepeatMode()) {
2067 coordinator.updatePlayMode("SHUFFLE");
2070 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
2073 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
2076 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2077 || command.equals(OpenClosedType.CLOSED)) {
2078 switch (coordinator.getRepeatMode()) {
2080 coordinator.updatePlayMode("REPEAT_ALL");
2083 coordinator.updatePlayMode("REPEAT_ONE");
2086 coordinator.updatePlayMode("NORMAL");
2090 } catch (IllegalStateException e) {
2091 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
2096 public void setRepeat(Command command) {
2097 if (command instanceof StringType) {
2099 ZonePlayerHandler coordinator = getCoordinatorHandler();
2101 switch (command.toString()) {
2103 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
2106 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
2109 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
2112 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
2113 command.toString());
2116 } catch (IllegalStateException e) {
2117 logger.debug("Cannot handle repeat command ({})", e.getMessage());
2122 public void setSubwoofer(Command command) {
2123 setEqualizerBooleanSetting(command, "SubEnable");
2126 public void setSubwooferGain(Command command) {
2127 setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
2130 public void setSurround(Command command) {
2131 setEqualizerBooleanSetting(command, "SurroundEnable");
2134 public void setSurroundMusicMode(Command command) {
2135 if (command instanceof StringType) {
2136 setEQ("SurroundMode", command.toString());
2140 public void setSurroundMusicLevel(Command command) {
2141 setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
2142 MAX_SURROUND_LEVEL);
2145 public void setSurroundTvLevel(Command command) {
2146 setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
2147 MAX_SURROUND_LEVEL);
2150 public void setHeightLevel(Command command) {
2151 setEqualizerNumericSetting(command, "HeightChannelLevel", getHeightLevel(), MIN_HEIGHT_LEVEL, MAX_HEIGHT_LEVEL);
2154 public void setNightMode(Command command) {
2155 setEqualizerBooleanSetting(command, "NightMode");
2158 public void setSpeechEnhancement(Command command) {
2159 setEqualizerBooleanSetting(command, "DialogLevel");
2162 private void setEqualizerBooleanSetting(Command command, String eqType) {
2163 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2164 setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2165 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
2169 private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
2171 String newValue = getNewNumericValue(command, currentValue, minValue, maxValue);
2172 if (newValue != null) {
2173 setEQ(eqType, newValue);
2177 private void setEQ(String eqType, String value) {
2179 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_EQ,
2180 Map.of("InstanceID", "0", "EQType", eqType, "DesiredValue", value));
2181 } catch (IllegalStateException e) {
2182 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2186 public @Nullable String getNightMode() {
2187 return stateMap.get("NightMode");
2190 public @Nullable String getDialogLevel() {
2191 return stateMap.get("DialogLevel");
2194 public @Nullable String getPlayMode() {
2195 return stateMap.get("CurrentPlayMode");
2198 public Boolean isShuffleActive() {
2199 String playMode = getPlayMode();
2200 return (playMode != null && playMode.startsWith("SHUFFLE"));
2203 public String getRepeatMode() {
2204 String mode = "OFF";
2205 String playMode = getPlayMode();
2206 if (playMode != null) {
2213 case "SHUFFLE_REPEAT_ONE":
2217 case "SHUFFLE_NOREPEAT":
2226 public @Nullable String getMicEnabled() {
2227 return stateMap.get("MicEnabled");
2230 protected void updatePlayMode(String playMode) {
2231 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_PLAY_MODE, Map.of("InstanceID", "0", "NewPlayMode", playMode));
2235 * Clear all scheduled music from the current queue.
2238 public void removeAllTracksFromQueue() {
2239 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE, Map.of("InstanceID", "0"));
2243 * Play music from the line-in of the given Player referenced by the given UDN or name
2245 * @param udn or name
2247 public void playLineIn(Command command) {
2248 if (command instanceof StringType) {
2250 LineInType lineInType = LineInType.ANY;
2251 String remotePlayerName = command.toString();
2252 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2253 lineInType = LineInType.ANALOG;
2254 remotePlayerName = remotePlayerName.substring(7);
2255 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2256 lineInType = LineInType.DIGITAL;
2257 remotePlayerName = remotePlayerName.substring(8);
2259 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2260 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2262 // check if player has a line-in connected
2263 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2264 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2265 // stop whatever is currently playing
2266 coordinatorHandler.stop();
2269 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2270 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2272 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2275 // take the system off mute
2276 coordinatorHandler.setMute(OnOffType.OFF);
2279 coordinatorHandler.play();
2281 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2283 } catch (IllegalStateException e) {
2284 logger.debug("Cannot play line-in ({})", e.getMessage());
2289 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2290 ZonePlayerHandler handler = coordinatorHandler;
2291 if (handler != null) {
2295 handler = getHandlerByName(getCoordinator());
2296 coordinatorHandler = handler;
2298 } catch (IllegalStateException e) {
2299 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2304 * Returns a list of all zone group members this particular player is member of
2305 * Or empty list if the players is not assigned to any group
2307 * @return a list of Strings containing the UDNs of other group members
2309 protected List<String> getZoneGroupMembers() {
2310 List<String> result = new ArrayList<>();
2312 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2313 if (!zoneGroups.isEmpty()) {
2314 for (SonosZoneGroup zg : zoneGroups) {
2315 if (zg.getMembers().contains(getUDN())) {
2316 result.addAll(zg.getMembers());
2321 // If the group topology was not yet received, return at least the current Sonos zone
2322 result.add(getUDN());
2328 * Returns a list of other zone group members this particular player is member of
2329 * Or empty list if the players is not assigned to any group
2331 * @return a list of Strings containing the UDNs of other group members
2333 protected List<String> getOtherZoneGroupMembers() {
2334 List<String> zoneGroupMembers = getZoneGroupMembers();
2335 zoneGroupMembers.remove(getUDN());
2336 return zoneGroupMembers;
2339 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2340 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2341 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2342 if (thing != null) {
2343 ThingHandler handler = thing.getHandler();
2344 if (handler instanceof ZonePlayerHandler) {
2345 return (ZonePlayerHandler) handler;
2349 for (Thing aThing : localThingRegistry.getAll()) {
2350 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2351 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2352 ThingHandler handler = aThing.getHandler();
2353 if (handler instanceof ZonePlayerHandler) {
2354 return (ZonePlayerHandler) handler;
2358 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2361 public void setMute(Command command) {
2362 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2363 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2364 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
2365 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_MUTE,
2366 Map.of("Channel", "Master", "DesiredMute", value));
2370 public List<SonosAlarm> getCurrentAlarmList() {
2371 Map<String, String> result = executeAction(SERVICE_ALARM_CLOCK, "ListAlarms", null);
2372 String alarmList = result.get("CurrentAlarmList");
2373 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2376 public void updateAlarm(SonosAlarm alarm) {
2377 Map<String, String> inputs = new HashMap<>();
2380 inputs.put("ID", Integer.toString(alarm.getId()));
2381 inputs.put("StartLocalTime", alarm.getStartTime());
2382 inputs.put("Duration", alarm.getDuration());
2383 inputs.put("Recurrence", alarm.getRecurrence());
2384 inputs.put("RoomUUID", alarm.getRoomUUID());
2385 inputs.put("ProgramURI", alarm.getProgramURI());
2386 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2387 inputs.put("PlayMode", alarm.getPlayMode());
2388 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2389 if (alarm.getIncludeLinkedZones()) {
2390 inputs.put("IncludeLinkedZones", "1");
2392 inputs.put("IncludeLinkedZones", "0");
2395 if (alarm.getEnabled()) {
2396 inputs.put("Enabled", "1");
2398 inputs.put("Enabled", "0");
2400 } catch (NumberFormatException ex) {
2401 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2404 executeAction(SERVICE_ALARM_CLOCK, "UpdateAlarm", inputs);
2407 public void setAlarm(Command command) {
2408 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2409 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2411 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2412 || command.equals(OpenClosedType.CLOSED)) {
2418 public void setAlarm(boolean alarmSwitch) {
2419 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2421 // find the nearest alarm - take the current time from the Sonos system,
2422 // not the system where we are running
2423 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2424 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2426 String currentLocalTime = getTime();
2427 Date currentDateTime = null;
2429 currentDateTime = fmt.parse(currentLocalTime);
2430 } catch (ParseException e) {
2431 logger.debug("An exception occurred while formatting a date", e);
2434 if (currentDateTime != null) {
2435 Calendar currentDateTimeCalendar = Calendar.getInstance();
2436 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2437 currentDateTimeCalendar.setTime(currentDateTime);
2438 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2439 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2441 SonosAlarm firstAlarm = null;
2443 for (SonosAlarm anAlarm : sonosAlarms) {
2444 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2445 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2448 durationDate = durationFormat.parse(anAlarm.getDuration());
2449 } catch (ParseException e) {
2450 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2454 long duration = durationDate.getTime();
2456 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2457 shortestDuration = duration;
2458 firstAlarm = anAlarm;
2463 if (firstAlarm != null) {
2465 firstAlarm.setEnabled(true);
2467 firstAlarm.setEnabled(false);
2470 updateAlarm(firstAlarm);
2475 public @Nullable String getTime() {
2477 return stateMap.get("CurrentLocalTime");
2480 public @Nullable String getAlarmRunning() {
2481 return stateMap.get("AlarmRunning");
2484 public boolean isAlarmRunning() {
2485 return "1".equals(getAlarmRunning());
2488 public void snoozeAlarm(Command command) {
2489 if (isAlarmRunning() && command instanceof DecimalType) {
2490 int minutes = ((DecimalType) command).intValue();
2492 Map<String, String> inputs = new HashMap<>();
2494 Calendar snoozePeriod = Calendar.getInstance();
2495 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2496 snoozePeriod.setTimeInMillis(0);
2497 snoozePeriod.add(Calendar.MINUTE, minutes);
2498 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2499 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2502 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2503 } catch (NumberFormatException ex) {
2504 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2507 executeAction(SERVICE_AV_TRANSPORT, ACTION_SNOOZE_ALARM, inputs);
2509 logger.debug("There is no alarm running on {}", getUDN());
2513 public @Nullable String getAnalogLineInConnected() {
2514 return stateMap.get(LINEINCONNECTED);
2517 public boolean isAnalogLineInConnected() {
2518 return "true".equals(getAnalogLineInConnected());
2521 public @Nullable String getOpticalLineInConnected() {
2522 return stateMap.get(TOSLINEINCONNECTED);
2525 public boolean isOpticalLineInConnected() {
2526 return "true".equals(getOpticalLineInConnected());
2529 public void becomeStandAlonePlayer() {
2530 executeAction(SERVICE_AV_TRANSPORT, ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP, null);
2533 public void addMember(Command command) {
2534 if (command instanceof StringType) {
2535 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2537 getHandlerByName(command.toString()).setCurrentURI(entry);
2538 } catch (IllegalStateException e) {
2539 logger.debug("Cannot add group member ({})", e.getMessage());
2544 @SuppressWarnings("PMD.CompareObjectsWithEquals")
2545 public boolean publicAddress(LineInType lineInType) {
2546 // check if sourcePlayer has a line-in connected
2547 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2548 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2549 // first remove this player from its own group if any
2550 becomeStandAlonePlayer();
2552 // add all other players to this new group
2553 for (SonosZoneGroup group : getZoneGroups()) {
2554 for (String player : group.getMembers()) {
2556 ZonePlayerHandler somePlayer = getHandlerByName(player);
2557 if (somePlayer != this) {
2558 somePlayer.becomeStandAlonePlayer();
2560 addMember(StringType.valueOf(somePlayer.getUDN()));
2562 } catch (IllegalStateException e) {
2563 logger.debug("Cannot add to group ({})", e.getMessage());
2569 ZonePlayerHandler coordinator = getCoordinatorHandler();
2570 // set the URI of the group to the line-in
2571 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2572 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2573 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2575 coordinator.setCurrentURI(entry);
2579 } catch (IllegalStateException e) {
2580 logger.debug("Cannot handle command ({})", e.getMessage());
2584 logger.debug("Line-in of {} is not connected", getUDN());
2590 * Play a given url to music in one of the music libraries.
2593 * in the format of //host/folder/filename.mp3
2595 public void playURI(Command command) {
2596 if (command instanceof StringType) {
2598 String url = command.toString();
2600 ZonePlayerHandler coordinator = getCoordinatorHandler();
2602 // stop whatever is currently playing
2604 coordinator.waitForNotTransportState(STATE_PLAYING);
2606 // clear any tracks which are pending in the queue
2607 coordinator.removeAllTracksFromQueue();
2609 // add the new track we want to play to the queue
2610 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2611 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2612 // default to file based url
2613 url = FILE_URI + url;
2615 coordinator.addURIToQueue(url, "", 0, true);
2617 // set the current playlist to our new queue
2618 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2620 // take the system off mute
2621 coordinator.setMute(OnOffType.OFF);
2625 } catch (IllegalStateException e) {
2626 logger.debug("Cannot play URI ({})", e.getMessage());
2627 } catch (InterruptedException e) {
2628 logger.debug("Play URI interrupted ({})", e.getMessage());
2629 Thread.currentThread().interrupt();
2634 private void scheduleNotificationSound(final Command command) {
2635 scheduler.submit(() -> {
2636 synchronized (notificationLock) {
2637 playNotificationSoundURI(command);
2643 * Play a given notification sound
2645 * @param url in the format of //host/folder/filename.mp3
2647 public void playNotificationSoundURI(Command notificationURL) {
2648 if (notificationURL instanceof StringType) {
2650 ZonePlayerHandler coordinator = getCoordinatorHandler();
2652 String currentURI = coordinator.getCurrentURI();
2653 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2654 coordinator.getCurrentURIMetadataAsString());
2656 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2657 || isPlayingRadio(currentURI)) {
2658 handleNotifForRadioStream(currentURI, notificationURL, coordinator);
2659 } else if (isPlayingLineIn(currentURI)) {
2660 handleNotifForLineIn(currentURI, notificationURL, coordinator);
2661 } else if (isPlayingVirtualLineIn(currentURI)) {
2662 handleNotifForVirtualLineIn(currentURI, notificationURL, coordinator);
2663 } else if (isPlayingQueue(currentURI)) {
2664 handleNotifForSharedQueue(currentURI, notificationURL, coordinator);
2665 } else if (isPlaylistEmpty(coordinator)) {
2666 handleNotifForEmptyQueue(notificationURL, coordinator);
2668 logger.debug("Notification feature not yet implemented while the current media is being played");
2670 synchronized (notificationLock) {
2671 notificationLock.notify();
2673 } catch (IllegalStateException e) {
2674 logger.debug("Cannot play notification sound ({})", e.getMessage());
2675 } catch (InterruptedException e) {
2676 logger.debug("Play notification sound interrupted ({})", e.getMessage());
2677 Thread.currentThread().interrupt();
2682 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2683 return coordinator.getQueueSize() == 0;
2686 private boolean isPlayingQueue(@Nullable String currentURI) {
2687 return currentURI != null && currentURI.contains(QUEUE_URI);
2690 private boolean isPlayingStream(@Nullable String currentURI) {
2691 return currentURI != null && currentURI.contains(STREAM_URI);
2694 private boolean isPlayingRadio(@Nullable String currentURI) {
2695 return currentURI != null && currentURI.contains(RADIO_URI);
2698 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2699 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2702 private boolean isPlayingLineIn(@Nullable String currentURI) {
2703 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2706 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2707 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2710 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2711 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2714 private boolean isPlayingVirtualLineIn(@Nullable String currentURI) {
2715 return currentURI != null && currentURI.startsWith(VIRTUAL_LINE_IN_URI);
2719 * Does a chain of predefined actions when a Notification sound is played by
2720 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2721 * radio streaming is currently loaded
2723 * @param currentStreamURI - the currently loaded stream's URI
2724 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2725 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2726 * @throws InterruptedException
2728 private void handleNotifForRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2729 ZonePlayerHandler coordinator) throws InterruptedException {
2730 String nextAction = coordinator.getTransportState();
2731 SonosMetaData track = coordinator.getTrackMetadata();
2732 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2734 handleNotificationSound(notificationURL, coordinator);
2735 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2736 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2737 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2738 restoreLastTransportState(coordinator, nextAction);
2743 * Does a chain of predefined actions when a Notification sound is played by
2744 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2745 * line in is currently loaded
2747 * @param currentLineInURI - the currently loaded line-in URI
2748 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2749 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2750 * @throws InterruptedException
2752 private void handleNotifForLineIn(@Nullable String currentLineInURI, Command notificationURL,
2753 ZonePlayerHandler coordinator) throws InterruptedException {
2754 logger.debug("Handling notification while sound from line-in was being played");
2755 String nextAction = coordinator.getTransportState();
2757 handleNotificationSound(notificationURL, coordinator);
2758 if (currentLineInURI != null) {
2759 logger.debug("Restoring sound from line-in using URI {}", currentLineInURI);
2760 coordinator.setCurrentURI(currentLineInURI, "");
2761 restoreLastTransportState(coordinator, nextAction);
2766 * Does a chain of predefined actions when a Notification sound is played by
2767 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2768 * virtual line in is currently loaded
2770 * @param currentVirtualLineInURI - the currently loaded virtual line-in URI
2771 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2772 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2773 * @throws InterruptedException
2775 private void handleNotifForVirtualLineIn(@Nullable String currentVirtualLineInURI, Command notificationURL,
2776 ZonePlayerHandler coordinator) throws InterruptedException {
2777 logger.debug("Handling notification while sound from virtual line-in was being played");
2778 String nextAction = coordinator.getTransportState();
2779 String currentUriMetaData = coordinator.getCurrentURIMetadataAsString();
2781 handleNotificationSound(notificationURL, coordinator);
2782 if (currentVirtualLineInURI != null && currentUriMetaData != null) {
2783 logger.debug("Restoring sound from virtual line-in using URI {} and metadata {}", currentVirtualLineInURI,
2784 currentUriMetaData);
2785 coordinator.setCurrentURI(currentVirtualLineInURI, currentUriMetaData);
2786 restoreLastTransportState(coordinator, nextAction);
2791 * Does a chain of predefined actions when a Notification sound is played by
2792 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2793 * shared queue is currently loaded
2795 * @param currentQueueURI - the currently loaded queue URI
2796 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2797 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2798 * @throws InterruptedException
2800 private void handleNotifForSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2801 ZonePlayerHandler coordinator) throws InterruptedException {
2802 String nextAction = coordinator.getTransportState();
2803 String trackPosition = coordinator.getRefreshedPosition();
2804 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2806 "Handling notification while playing queue: currentQueueURI {} trackPosition {} currentTrackNumber {}",
2807 currentQueueURI, trackPosition, currentTrackNumber);
2809 handleNotificationSound(notificationURL, coordinator);
2810 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2811 if (queueUri.equals(currentQueueURI)) {
2812 coordinator.setPositionTrack(currentTrackNumber);
2813 coordinator.setPosition(trackPosition);
2814 restoreLastTransportState(coordinator, nextAction);
2819 * Handle the execution of the notification sound by sequentially executing the required steps.
2821 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2822 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2823 * @throws InterruptedException
2825 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator)
2826 throws InterruptedException {
2827 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2828 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2829 if (sourceStoppable) {
2831 coordinator.waitForNotTransportState(STATE_PLAYING);
2832 applyNotificationSoundVolume();
2834 long notificationPosition = coordinator.getQueueSize() + 1;
2835 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2836 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2837 coordinator.setPositionTrack(notificationPosition);
2838 if (!sourceStoppable) {
2840 coordinator.waitForNotTransportState(STATE_PLAYING);
2841 applyNotificationSoundVolume();
2844 coordinator.waitForFinishedNotification();
2845 if (originalVolume != null) {
2846 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2848 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2851 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction)
2852 throws InterruptedException {
2853 if (nextAction != null) {
2854 switch (nextAction) {
2857 coordinator.waitForTransportState(STATE_PLAYING);
2859 case STATE_PAUSED_PLAYBACK:
2860 coordinator.pause();
2867 * Does a chain of predefined actions when a Notification sound is played by
2868 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2869 * empty queue is currently loaded
2871 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2872 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2873 * @throws InterruptedException
2875 private void handleNotifForEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator)
2876 throws InterruptedException {
2877 String originalVolume = coordinator.getVolume();
2878 coordinator.applyNotificationSoundVolume();
2879 coordinator.playURI(notificationURL);
2880 coordinator.waitForFinishedNotification();
2881 coordinator.removeAllTracksFromQueue();
2882 if (originalVolume != null) {
2883 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2888 * Applies the notification sound volume level to the group (if not null)
2890 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2892 private void applyNotificationSoundVolume() {
2893 setNotificationSoundVolume(getNotificationSoundVolume());
2896 private void waitForFinishedNotification() throws InterruptedException {
2897 waitForTransportState(STATE_PLAYING);
2899 // check Sonos state events to determine the end of the notification sound
2900 String notificationTitle = getCurrentTitle();
2901 long playstart = System.currentTimeMillis();
2902 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2904 String currentTitle = getCurrentTitle();
2905 if ((notificationTitle == null && currentTitle != null)
2906 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2907 || !STATE_PLAYING.equals(getTransportState())) {
2913 private void waitForTransportState(String state) throws InterruptedException {
2914 if (getTransportState() != null) {
2915 long start = System.currentTimeMillis();
2916 while (!state.equals(getTransportState())) {
2918 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2925 private void waitForNotTransportState(String state) throws InterruptedException {
2926 if (getTransportState() != null) {
2927 long start = System.currentTimeMillis();
2928 while (state.equals(getTransportState())) {
2930 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2938 * Removes a range of tracks from the queue.
2939 * (<x,y> will remove y songs started by the song number x)
2941 * @param command - must be in the format <startIndex, numberOfSongs>
2943 public void removeRangeOfTracksFromQueue(Command command) {
2944 if (command instanceof StringType) {
2945 String[] rangeInputSplit = command.toString().split(",");
2946 // If range input is incorrect, remove the first song by default
2947 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2948 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2949 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE,
2950 Map.of("InstanceID", "0", "StartingIndex", startIndex, "NumberOfTracks", numberOfTracks));
2954 public void clearQueue() {
2956 ZonePlayerHandler coordinator = getCoordinatorHandler();
2958 coordinator.removeAllTracksFromQueue();
2959 } catch (IllegalStateException e) {
2960 logger.debug("Cannot clear queue ({})", e.getMessage());
2964 public void playQueue() {
2966 ZonePlayerHandler coordinator = getCoordinatorHandler();
2968 // set the current playlist to our new queue
2969 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2971 // take the system off mute
2972 coordinator.setMute(OnOffType.OFF);
2976 } catch (IllegalStateException e) {
2977 logger.debug("Cannot play queue ({})", e.getMessage());
2981 public void setLed(Command command) {
2982 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2983 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2984 || command.equals(OpenClosedType.OPEN)) ? "On" : "Off";
2985 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_SET_LED_STATE, Map.of("DesiredLEDState", value));
2986 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
2990 public void removeMember(Command command) {
2991 if (command instanceof StringType) {
2993 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2995 oldmemberHandler.becomeStandAlonePlayer();
2996 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2997 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2998 oldmemberHandler.setCurrentURI(entry);
2999 } catch (IllegalStateException e) {
3000 logger.debug("Cannot remove group member ({})", e.getMessage());
3005 public void previous() {
3006 executeAction(SERVICE_AV_TRANSPORT, ACTION_PREVIOUS, null);
3009 public void next() {
3010 executeAction(SERVICE_AV_TRANSPORT, ACTION_NEXT, null);
3013 public void stopPlaying(Command command) {
3014 if (command instanceof OnOffType) {
3016 getCoordinatorHandler().stop();
3017 } catch (IllegalStateException e) {
3018 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
3023 public void playRadio(Command command) {
3024 if (command instanceof StringType) {
3025 String station = command.toString();
3026 List<SonosEntry> stations = getFavoriteRadios();
3028 SonosEntry theEntry = null;
3029 // search for the appropriate radio based on its name (title)
3030 for (SonosEntry someStation : stations) {
3031 if (someStation.getTitle().equals(station)) {
3032 theEntry = someStation;
3037 // set the URI of the group coordinator
3038 if (theEntry != null) {
3040 ZonePlayerHandler coordinator = getCoordinatorHandler();
3041 coordinator.setCurrentURI(theEntry);
3043 } catch (IllegalStateException e) {
3044 logger.debug("Cannot play radio ({})", e.getMessage());
3047 logger.debug("Radio station '{}' not found", station);
3052 public void playTuneinStation(Command command) {
3053 if (command instanceof StringType) {
3054 String stationId = command.toString();
3055 List<SonosMusicService> allServices = getAvailableMusicServices();
3057 SonosMusicService tuneinService = null;
3058 // search for the TuneIn music service based on its name
3059 if (allServices != null) {
3060 for (SonosMusicService service : allServices) {
3061 if ("TuneIn".equals(service.getName())) {
3062 tuneinService = service;
3068 // set the URI of the group coordinator
3069 if (tuneinService != null) {
3071 ZonePlayerHandler coordinator = getCoordinatorHandler();
3072 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
3073 "object.item.audioItem.audioBroadcast",
3074 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
3075 Integer tuneinServiceType = tuneinService.getType();
3076 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
3077 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
3078 coordinator.setCurrentURI(entry);
3080 } catch (IllegalStateException e) {
3081 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
3084 logger.debug("TuneIn service not found");
3089 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
3090 if (musicServices == null) {
3091 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
3093 String serviceList = result.get("AvailableServiceDescriptorList");
3094 if (serviceList != null) {
3095 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
3096 musicServices = services;
3098 String[] servicesTypes = new String[0];
3099 String serviceTypeList = result.get("AvailableServiceTypeList");
3100 if (serviceTypeList != null) {
3101 // It is a comma separated list of service types (integers) in the same order as the services
3102 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
3104 servicesTypes = serviceTypeList.split(",");
3108 for (SonosMusicService service : services) {
3109 if (!"TuneIn".equals(service.getName())) {
3110 // Add the service type integer value from "AvailableServiceTypeList" to each service
3112 if (idx < servicesTypes.length) {
3114 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
3115 service.setType(serviceType);
3116 } catch (NumberFormatException e) {
3121 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3123 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3128 return musicServices;
3132 * This will attempt to match the station string with a entry in the
3133 * favorites list, this supports both single entries and playlists
3135 * @param favorite to match
3136 * @return true if a match was found and played.
3138 public void playFavorite(Command command) {
3139 if (command instanceof StringType) {
3140 String favorite = command.toString();
3141 List<SonosEntry> favorites = getFavorites();
3143 SonosEntry theEntry = null;
3144 // search for the appropriate favorite based on its name (title)
3145 for (SonosEntry entry : favorites) {
3146 if (entry.getTitle().equals(favorite)) {
3152 // set the URI of the group coordinator
3153 if (theEntry != null) {
3155 ZonePlayerHandler coordinator = getCoordinatorHandler();
3158 * If this is a playlist we need to treat it as such
3160 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3161 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3162 coordinator.removeAllTracksFromQueue();
3163 coordinator.addURIToQueue(theEntry);
3164 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3165 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3166 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3168 coordinator.setCurrentURI(theEntry);
3171 } catch (IllegalStateException e) {
3172 logger.debug("Cannot paly favorite ({})", e.getMessage());
3175 logger.debug("Favorite '{}' not found", favorite);
3180 public void playTrack(Command command) {
3181 if (command instanceof DecimalType) {
3183 ZonePlayerHandler coordinator = getCoordinatorHandler();
3185 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3187 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3189 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3190 coordinator.setPositionTrack(trackNumber);
3192 // take the system off mute
3193 coordinator.setMute(OnOffType.OFF);
3197 } catch (IllegalStateException e) {
3198 logger.debug("Cannot play track ({})", e.getMessage());
3203 public void playPlayList(Command command) {
3204 if (command instanceof StringType) {
3205 String playlist = command.toString();
3206 List<SonosEntry> playlists = getPlayLists();
3208 SonosEntry theEntry = null;
3209 // search for the appropriate play list based on its name (title)
3210 for (SonosEntry somePlaylist : playlists) {
3211 if (somePlaylist.getTitle().equals(playlist)) {
3212 theEntry = somePlaylist;
3217 // set the URI of the group coordinator
3218 if (theEntry != null) {
3220 ZonePlayerHandler coordinator = getCoordinatorHandler();
3222 coordinator.addURIToQueue(theEntry);
3224 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3226 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3227 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3230 } catch (IllegalStateException e) {
3231 logger.debug("Cannot play playlist ({})", e.getMessage());
3234 logger.debug("Playlist '{}' not found", playlist);
3239 public void addURIToQueue(SonosEntry newEntry) {
3240 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3243 public @Nullable String getZoneName() {
3244 return stateMap.get("ZoneName");
3247 public @Nullable String getZoneGroupID() {
3248 return stateMap.get("LocalGroupUUID");
3251 public @Nullable String getRunningAlarmProperties() {
3252 return stateMap.get("RunningAlarmProperties");
3255 public @Nullable String getRefreshedRunningAlarmProperties() {
3256 updateRunningAlarmProperties();
3257 return getRunningAlarmProperties();
3260 public @Nullable String getMute() {
3261 return stateMap.get("MuteMaster");
3264 public @Nullable String getLed() {
3265 return stateMap.get("CurrentLEDState");
3268 public @Nullable String getCurrentZoneName() {
3269 return stateMap.get("CurrentZoneName");
3272 public @Nullable String getRefreshedCurrentZoneName() {
3273 updateCurrentZoneName();
3274 return getCurrentZoneName();
3278 public void onStatusChanged(boolean status) {
3280 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3281 if (getThing().getStatus() != ThingStatus.ONLINE) {
3282 updateStatus(ThingStatus.ONLINE);
3283 scheduler.execute(this::poll);
3286 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3287 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3291 private @Nullable String getModelNameFromDescriptor() {
3292 URL descriptor = service.getDescriptorURL(this);
3293 if (descriptor != null) {
3294 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3295 return sonosModelDescription == null ? null : SonosXMLParser.extractModelName(sonosModelDescription);
3301 private boolean migrateThingType() {
3302 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3303 String modelName = getModelNameFromDescriptor();
3304 if (modelName != null && isSupportedModel(modelName)) {
3305 updateSonosThingType(modelName);
3312 private boolean isSupportedModel(String modelName) {
3313 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3314 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3321 private void updateSonosThingType(String newThingTypeID) {
3322 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3326 * Set the sleeptimer duration
3327 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3328 * Use empty String "" to switch the sleep timer off
3330 public void setSleepTimer(Command command) {
3331 if (command instanceof DecimalType) {
3332 this.service.invokeAction(this, SERVICE_AV_TRANSPORT, ACTION_CONFIGURE_SLEEP_TIMER, Map.of("InstanceID",
3333 "0", "NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue())));
3337 protected void updateSleepTimerDuration() {
3338 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_REMAINING_SLEEP_TIMER_DURATION, null);
3341 private String sleepSecondsToTimeStr(long sleepSeconds) {
3342 if (sleepSeconds == 0) {
3344 } else if (sleepSeconds < 68400) {
3345 long remainingSeconds = sleepSeconds;
3346 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3347 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3348 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3349 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3350 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3351 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3353 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3358 private long sleepStrTimeToSeconds(String sleepTime) {
3359 String[] units = sleepTime.split(":");
3360 int hours = Integer.parseInt(units[0]);
3361 int minutes = Integer.parseInt(units[1]);
3362 int seconds = Integer.parseInt(units[2]);
3363 return 3600 * hours + 60 * minutes + seconds;
3366 private @Nullable String extractInfoFromMoreInfo(String searchedInfo) {
3367 String value = stateMap.get("MoreInfo");
3368 if (value != null) {
3369 String[] fields = value.split(",");
3370 for (int i = 0; i < fields.length; i++) {
3371 String[] pair = fields[i].trim().split(":");
3372 if (pair.length == 2 && searchedInfo.equalsIgnoreCase(pair[0].trim())) {
3373 return pair[1].trim();