2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.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 RADIOAPP_URI = "x-sonosapi-hls:radioapp_";
96 private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
97 private static final String FILE_URI = "x-file-cifs:";
98 private static final String SPDIF = ":spdif";
99 private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
101 private static final String STATE_PLAYING = "PLAYING";
102 private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
103 private static final String STATE_STOPPED = "STOPPED";
104 private static final String STATE_TRANSITIONING = "TRANSITIONING";
106 private static final String LINEINCONNECTED = "LineInConnected";
107 private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
109 private static final String SERVICE_DEVICE_PROPERTIES = "DeviceProperties";
110 private static final String SERVICE_AV_TRANSPORT = "AVTransport";
111 private static final String SERVICE_RENDERING_CONTROL = "RenderingControl";
112 private static final String SERVICE_ZONE_GROUP_TOPOLOGY = "ZoneGroupTopology";
113 private static final String SERVICE_GROUP_MANAGEMENT = "GroupManagement";
114 private static final String SERVICE_AUDIO_IN = "AudioIn";
115 private static final String SERVICE_HT_CONTROL = "HTControl";
116 private static final String SERVICE_CONTENT_DIRECTORY = "ContentDirectory";
117 private static final String SERVICE_ALARM_CLOCK = "AlarmClock";
119 private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList(SERVICE_DEVICE_PROPERTIES,
120 SERVICE_AV_TRANSPORT, SERVICE_ZONE_GROUP_TOPOLOGY, SERVICE_GROUP_MANAGEMENT, SERVICE_RENDERING_CONTROL,
121 SERVICE_AUDIO_IN, SERVICE_HT_CONTROL, SERVICE_CONTENT_DIRECTORY);
122 protected static final int SUBSCRIPTION_DURATION = 1800;
124 private static final String ACTION_GET_ZONE_ATTRIBUTES = "GetZoneAttributes";
125 private static final String ACTION_GET_ZONE_INFO = "GetZoneInfo";
126 private static final String ACTION_GET_LED_STATE = "GetLEDState";
127 private static final String ACTION_SET_LED_STATE = "SetLEDState";
129 private static final String ACTION_GET_POSITION_INFO = "GetPositionInfo";
130 private static final String ACTION_SET_AV_TRANSPORT_URI = "SetAVTransportURI";
131 private static final String ACTION_SEEK = "Seek";
132 private static final String ACTION_PLAY = "Play";
133 private static final String ACTION_STOP = "Stop";
134 private static final String ACTION_PAUSE = "Pause";
135 private static final String ACTION_PREVIOUS = "Previous";
136 private static final String ACTION_NEXT = "Next";
137 private static final String ACTION_ADD_URI_TO_QUEUE = "AddURIToQueue";
138 private static final String ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE = "RemoveTrackRangeFromQueue";
139 private static final String ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE = "RemoveAllTracksFromQueue";
140 private static final String ACTION_SAVE_QUEUE = "SaveQueue";
141 private static final String ACTION_SET_PLAY_MODE = "SetPlayMode";
142 private static final String ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP = "BecomeCoordinatorOfStandaloneGroup";
143 private static final String ACTION_GET_RUNNING_ALARM_PROPERTIES = "GetRunningAlarmProperties";
144 private static final String ACTION_SNOOZE_ALARM = "SnoozeAlarm";
145 private static final String ACTION_GET_REMAINING_SLEEP_TIMER_DURATION = "GetRemainingSleepTimerDuration";
146 private static final String ACTION_CONFIGURE_SLEEP_TIMER = "ConfigureSleepTimer";
148 private static final String ACTION_SET_VOLUME = "SetVolume";
149 private static final String ACTION_SET_MUTE = "SetMute";
150 private static final String ACTION_SET_BASS = "SetBass";
151 private static final String ACTION_SET_TREBLE = "SetTreble";
152 private static final String ACTION_SET_LOUDNESS = "SetLoudness";
153 private static final String ACTION_SET_EQ = "SetEQ";
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 static final int HTTP_TIMEOUT = 5000;
170 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
172 private final ThingRegistry localThingRegistry;
173 private final UpnpIOService service;
174 private final @Nullable String opmlUrl;
175 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
177 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
180 * Intrinsic lock used to synchronize the execution of notification sounds
182 private final Object notificationLock = new Object();
183 private final Object upnpLock = new Object();
184 private final Object stateLock = new Object();
185 private final Object jobLock = new Object();
187 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
189 private @Nullable ScheduledFuture<?> pollingJob;
190 private @Nullable SonosZonePlayerState savedState;
192 private Map<String, Boolean> subscriptionState = new HashMap<>();
195 * Thing handler instance of the coordinator speaker used for control delegation
197 private @Nullable ZonePlayerHandler coordinatorHandler;
199 private @Nullable List<SonosMusicService> musicServices;
201 private enum LineInType {
207 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
208 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
210 this.localThingRegistry = thingRegistry;
211 this.opmlUrl = opmlUrl;
212 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
213 this.service = upnpIOService;
214 this.stateDescriptionProvider = stateDescriptionProvider;
218 public void dispose() {
219 logger.debug("Handler disposed for thing {}", getThing().getUID());
221 ScheduledFuture<?> job = this.pollingJob;
225 this.pollingJob = null;
227 removeSubscription();
228 service.unregisterParticipant(this);
232 public void initialize() {
233 logger.debug("initializing handler for thing {}", getThing().getUID());
235 if (migrateThingType()) {
236 // we change the type, so we might need a different handler -> let's finish
240 configuration = getConfigAs(ZonePlayerConfiguration.class);
241 String udn = configuration.udn;
242 if (udn != null && !udn.isEmpty()) {
243 service.registerParticipant(this);
244 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
246 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
247 "@text/offline.conf-error-missing-udn");
248 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
252 private void poll() {
253 synchronized (jobLock) {
254 if (pollingJob == null) {
258 logger.debug("Polling job");
260 // First check if the Sonos zone is set in the UPnP service registry
261 // If not, set the thing state to OFFLINE and wait for the next poll
262 if (!isUpnpDeviceRegistered()) {
263 logger.debug("UPnP device {} not yet registered", getUDN());
264 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
265 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
266 synchronized (upnpLock) {
267 subscriptionState = new HashMap<>();
272 // Check if the Sonos zone can be joined
273 // If not, set the thing state to OFFLINE and do nothing else
275 if (getThing().getStatus() != ThingStatus.ONLINE) {
281 if (isLinked(ZONENAME)) {
282 updateCurrentZoneName();
287 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
288 if (isLinked(SLEEPTIMER) && isCoordinator()) {
289 updateSleepTimerDuration();
291 } catch (Exception e) {
292 logger.debug("Exception during poll: {}", e.getMessage(), e);
298 public void handleCommand(ChannelUID channelUID, Command command) {
299 if (command == RefreshType.REFRESH) {
300 updateChannel(channelUID.getId());
302 switch (channelUID.getId()) {
309 case NOTIFICATIONSOUND:
310 scheduleNotificationSound(command);
313 stopPlaying(command);
316 setVolumeForGroup(command);
325 setLoudness(command);
328 setSubwoofer(command);
331 setSubwooferGain(command);
334 setSurround(command);
336 case SURROUNDMUSICMODE:
337 setSurroundMusicMode(command);
339 case SURROUNDMUSICLEVEL:
340 setSurroundMusicLevel(command);
342 case SURROUNDTVLEVEL:
343 setSurroundTvLevel(command);
346 setHeightLevel(command);
352 removeMember(command);
355 becomeStandAlonePlayer();
358 publicAddress(LineInType.ANY);
360 case PUBLICANALOGADDRESS:
361 publicAddress(LineInType.ANALOG);
363 case PUBLICDIGITALADDRESS:
364 publicAddress(LineInType.DIGITAL);
369 case TUNEINSTATIONID:
370 playTuneinStation(command);
373 playFavorite(command);
379 snoozeAlarm(command);
382 saveAllPlayerState();
385 restoreAllPlayerState();
394 playPlayList(command);
413 if (command instanceof PlayPauseType) {
414 if (command == PlayPauseType.PLAY) {
415 getCoordinatorHandler().play();
416 } else if (command == PlayPauseType.PAUSE) {
417 getCoordinatorHandler().pause();
420 if (command instanceof NextPreviousType) {
421 if (command == NextPreviousType.NEXT) {
422 getCoordinatorHandler().next();
423 } else if (command == NextPreviousType.PREVIOUS) {
424 getCoordinatorHandler().previous();
427 // Rewind and Fast Forward are currently not implemented by the binding
428 } catch (IllegalStateException e) {
429 logger.debug("Cannot handle control command ({})", e.getMessage());
433 setSleepTimer(command);
442 setNightMode(command);
444 case SPEECHENHANCEMENT:
445 setSpeechEnhancement(command);
453 private void restoreAllPlayerState() {
454 for (Thing aThing : localThingRegistry.getAll()) {
455 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
456 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
457 if (handler != null) {
458 handler.restoreState();
464 private void saveAllPlayerState() {
465 for (Thing aThing : localThingRegistry.getAll()) {
466 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
467 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
468 if (handler != null) {
476 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
477 if (variable == null || value == null || service == null) {
481 if (getThing().getStatus() == ThingStatus.ONLINE) {
482 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
483 new Object[] { variable, value, service, this.getThing().getUID() });
485 String oldValue = this.stateMap.get(variable);
486 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
490 this.stateMap.put(variable, value);
492 // pre-process some variables, eg XML processing
493 if (SERVICE_AV_TRANSPORT.equals(service) && "LastChange".equals(variable)) {
494 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
495 parsedValues.forEach((variable1, value1) -> {
496 // Update the transport state after the update of the media information
497 // to not break the notification mechanism
498 if (!"TransportState".equals(variable1)) {
499 onValueReceived(variable1, value1, service);
501 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
502 // for a compatibility with the result of the action GetMediaInfo
503 if ("AVTransportURI".equals(variable1)) {
504 onValueReceived("CurrentURI", value1, service);
505 } else if ("AVTransportURIMetaData".equals(variable1)) {
506 onValueReceived("CurrentURIMetaData", value1, service);
509 updateMediaInformation();
510 if (parsedValues.get("TransportState") != null) {
511 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
515 if (SERVICE_RENDERING_CONTROL.equals(service) && "LastChange".equals(variable)) {
516 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
517 parsedValues.forEach((variable1, value1) -> {
518 onValueReceived(variable1, value1, service);
522 List<StateOption> options = new ArrayList<>();
524 // update the appropriate channel
526 case "TransportState":
527 updateChannel(STATE);
528 updateChannel(CONTROL);
530 dispatchOnAllGroupMembers(variable, value, service);
532 case "CurrentPlayMode":
533 updateChannel(SHUFFLE);
534 updateChannel(REPEAT);
535 dispatchOnAllGroupMembers(variable, value, service);
537 case "CurrentLEDState":
541 updateState(ZONENAME, new StringType(value));
543 case "CurrentZoneName":
544 updateChannel(ZONENAME);
546 case "ZoneGroupState":
547 updateChannel(COORDINATOR);
548 // Update coordinator after a change is made to the grouping of Sonos players
549 updateGroupCoordinator();
550 updateMediaInformation();
551 // Update state and control channels for the group members with the coordinator values
552 String transportState = getTransportState();
553 if (transportState != null) {
554 dispatchOnAllGroupMembers("TransportState", transportState, SERVICE_AV_TRANSPORT);
556 // Update shuffle and repeat channels for the group members with the coordinator values
557 String playMode = getPlayMode();
558 if (playMode != null) {
559 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, SERVICE_AV_TRANSPORT);
562 case "LocalGroupUUID":
563 updateChannel(ZONEGROUPID);
565 case "GroupCoordinatorIsLocal":
566 updateChannel(LOCALCOORDINATOR);
569 updateChannel(VOLUME);
578 updateChannel(TREBLE);
580 case "LoudnessMaster":
581 updateChannel(LOUDNESS);
585 updateChannel(TREBLE);
586 updateChannel(LOUDNESS);
589 updateChannel(SUBWOOFER);
592 updateChannel(SUBWOOFERGAIN);
594 case "SurroundEnabled":
595 updateChannel(SURROUND);
598 updateChannel(SURROUNDMUSICMODE);
600 case "SurroundLevel":
601 updateChannel(SURROUNDTVLEVEL);
604 updateChannel(CODEC);
606 case "MusicSurroundLevel":
607 updateChannel(SURROUNDMUSICLEVEL);
609 case "HeightChannelLevel":
610 updateChannel(HEIGHTLEVEL);
613 updateChannel(NIGHTMODE);
616 updateChannel(SPEECHENHANCEMENT);
618 case LINEINCONNECTED:
619 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
620 updateChannel(LINEIN);
622 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
623 .contains(getThing().getThingTypeUID())) {
624 updateChannel(ANALOGLINEIN);
627 case TOSLINEINCONNECTED:
628 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
629 updateChannel(LINEIN);
631 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
632 .contains(getThing().getThingTypeUID())) {
633 updateChannel(DIGITALLINEIN);
637 updateChannel(ALARMRUNNING);
638 updateRunningAlarmProperties();
640 case "RunningAlarmProperties":
641 updateChannel(ALARMPROPERTIES);
643 case "CurrentURIFormatted":
644 updateChannel(CURRENTTRACK);
647 updateChannel(CURRENTTITLE);
649 case "CurrentArtist":
650 updateChannel(CURRENTARTIST);
653 updateChannel(CURRENTALBUM);
656 updateChannel(CURRENTTRANSPORTURI);
658 case "CurrentTrackURI":
659 updateChannel(CURRENTTRACKURI);
661 case "CurrentAlbumArtURI":
662 updateChannel(CURRENTALBUMARTURL);
664 case "CurrentSleepTimerGeneration":
665 if ("0".equals(value)) {
666 updateState(SLEEPTIMER, new DecimalType(0));
669 case "SleepTimerGeneration":
670 if ("0".equals(value)) {
671 updateState(SLEEPTIMER, new DecimalType(0));
673 updateSleepTimerDuration();
676 case "RemainingSleepTimerDuration":
677 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
679 case "CurrentTuneInStationId":
680 updateChannel(TUNEINSTATIONID);
682 case "SavedQueuesUpdateID": // service ContentDirectoy
683 for (SonosEntry entry : getPlayLists()) {
684 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
686 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
688 case "FavoritesUpdateID": // service ContentDirectoy
689 for (SonosEntry entry : getFavorites()) {
690 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
692 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
694 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
695 // Due to a bug in the data type definition of this state variable, it is not set.
696 // As a workaround, we check the state variable named ContainerUpdateIDs.
697 case "ContainerUpdateIDs": // service ContentDirectoy
698 if (value.startsWith("R:0,") || stateDescriptionProvider
699 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
700 for (SonosEntry entry : getFavoriteRadios()) {
701 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
703 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
707 updateChannel(BATTERYCHARGING);
708 updateChannel(BATTERYLEVEL);
711 updateChannel(MICROPHONE);
719 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
720 if (isCoordinator()) {
721 for (String member : getOtherZoneGroupMembers()) {
723 ZonePlayerHandler memberHandler = getHandlerByName(member);
724 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
725 memberHandler.onValueReceived(variable, value, service);
727 } catch (IllegalStateException e) {
728 logger.debug("Cannot update channel for group member ({})", e.getMessage());
734 private @Nullable String getAlbumArtUrl() {
736 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
737 if (albumArtURI != null) {
738 if (albumArtURI.startsWith("http")) {
740 } else if (albumArtURI.startsWith("/")) {
742 URL serviceDescrUrl = service.getDescriptorURL(this);
743 if (serviceDescrUrl != null) {
744 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
745 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
747 } catch (MalformedURLException e) {
748 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
755 protected void updateChannel(String channelId) {
756 if (!isLinked(channelId)) {
762 State newState = UnDefType.UNDEF;
766 value = getTransportState();
768 // Ignoring state TRANSITIONING
769 newState = STATE_TRANSITIONING.equals(value) ? null : new StringType(value);
773 value = getTransportState();
774 if (STATE_PLAYING.equals(value)) {
775 newState = PlayPauseType.PLAY;
776 } else if (STATE_STOPPED.equals(value)) {
777 newState = PlayPauseType.PAUSE;
778 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
779 newState = PlayPauseType.PAUSE;
780 } else if (STATE_TRANSITIONING.equals(value)) {
781 // Ignoring state TRANSITIONING
786 value = getTransportState();
788 newState = STATE_TRANSITIONING.equals(value) ? null : OnOffType.from(STATE_STOPPED.equals(value));
792 if (getPlayMode() != null) {
793 newState = OnOffType.from(isShuffleActive());
797 if (getPlayMode() != null) {
798 newState = new StringType(getRepeatMode());
804 newState = OnOffType.from(value);
808 value = getCurrentZoneName();
810 newState = new StringType(value);
814 value = getZoneGroupID();
816 newState = new StringType(value);
820 newState = new StringType(getCoordinator());
822 case LOCALCOORDINATOR:
823 if (getGroupCoordinatorIsLocal() != null) {
824 newState = OnOffType.from(isGroupCoordinator());
830 newState = new PercentType(value);
835 if (value != null && !isOutputLevelFixed()) {
836 newState = new DecimalType(value);
841 if (value != null && !isOutputLevelFixed()) {
842 newState = new DecimalType(value);
846 value = getLoudness();
847 if (value != null && !isOutputLevelFixed()) {
848 newState = OnOffType.from(value);
854 newState = OnOffType.from(value);
858 value = getSubwooferEnabled();
860 newState = OnOffType.from(value);
864 value = getSubwooferGain();
866 newState = new DecimalType(value);
870 value = getSurroundEnabled();
872 newState = OnOffType.from(value);
875 case SURROUNDMUSICMODE:
876 value = getSurroundMusicMode();
878 newState = new StringType(value);
881 case SURROUNDMUSICLEVEL:
882 value = getSurroundMusicLevel();
884 newState = new DecimalType(value);
887 case SURROUNDTVLEVEL:
888 value = getSurroundTvLevel();
890 newState = new DecimalType(value);
896 newState = new StringType(value);
900 value = getHeightLevel();
902 newState = new DecimalType(value);
906 value = getNightMode();
908 newState = OnOffType.from(value);
911 case SPEECHENHANCEMENT:
912 value = getDialogLevel();
914 newState = OnOffType.from(value);
918 if (getAnalogLineInConnected() != null) {
919 newState = OnOffType.from(isAnalogLineInConnected());
920 } else if (getOpticalLineInConnected() != null) {
921 newState = OnOffType.from(isOpticalLineInConnected());
925 if (getAnalogLineInConnected() != null) {
926 newState = OnOffType.from(isAnalogLineInConnected());
930 if (getOpticalLineInConnected() != null) {
931 newState = OnOffType.from(isOpticalLineInConnected());
935 if (getAlarmRunning() != null) {
936 newState = OnOffType.from(isAlarmRunning());
939 case ALARMPROPERTIES:
940 value = getRunningAlarmProperties();
942 newState = new StringType(value);
946 value = stateMap.get("CurrentURIFormatted");
948 newState = new StringType(value);
952 value = getCurrentTitle();
954 newState = new StringType(value);
958 value = getCurrentArtist();
960 newState = new StringType(value);
964 value = getCurrentAlbum();
966 newState = new StringType(value);
969 case CURRENTALBUMART:
971 updateAlbumArtChannel(false);
973 case CURRENTALBUMARTURL:
974 url = getAlbumArtUrl();
976 newState = new StringType(url);
979 case CURRENTTRANSPORTURI:
980 value = getCurrentURI();
982 newState = new StringType(value);
985 case CURRENTTRACKURI:
986 value = stateMap.get("CurrentTrackURI");
988 newState = new StringType(value);
991 case TUNEINSTATIONID:
992 value = stateMap.get("CurrentTuneInStationId");
994 newState = new StringType(value);
997 case BATTERYCHARGING:
998 value = extractInfoFromMoreInfo("BattChg");
1000 newState = OnOffType.from("CHARGING".equalsIgnoreCase(value));
1004 value = extractInfoFromMoreInfo("BattPct");
1005 if (value != null) {
1006 newState = new DecimalType(value);
1010 value = getMicEnabled();
1011 if (value != null) {
1012 newState = OnOffType.from(value);
1019 if (newState != null) {
1020 updateState(channelId, newState);
1024 private void updateAlbumArtChannel(boolean allGroup) {
1025 String url = getAlbumArtUrl();
1027 // We download the cover art in a different thread to not delay the other operations
1028 scheduler.submit(() -> {
1029 RawType image = HttpUtil.downloadImage(url, true, 500000);
1030 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
1033 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
1037 private void updateChannel(String channeldD, State state, boolean allGroup) {
1039 for (String member : getZoneGroupMembers()) {
1041 ZonePlayerHandler memberHandler = getHandlerByName(member);
1042 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
1043 && memberHandler.isLinked(channeldD)) {
1044 memberHandler.updateState(channeldD, state);
1046 } catch (IllegalStateException e) {
1047 logger.debug("Cannot update channel for group member ({})", e.getMessage());
1050 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
1051 updateState(channeldD, state);
1056 * CurrentURI will not change, but will trigger change of CurrentURIFormated
1057 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
1059 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
1060 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
1063 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
1064 return oldValue != null ? !oldValue.equals(value) : value != null;
1068 * Similar to the AVTransport eventing, the Queue events its state variables
1069 * as sub values within a synthesized LastChange state variable.
1071 private boolean isQueueEvent(String variable) {
1072 return "LastChange".equals(variable);
1075 private void updateGroupCoordinator() {
1077 coordinatorHandler = getHandlerByName(getCoordinator());
1078 } catch (IllegalStateException e) {
1079 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
1080 coordinatorHandler = null;
1084 private boolean isUpnpDeviceRegistered() {
1085 return service.isRegistered(this);
1088 private void addSubscription() {
1089 synchronized (upnpLock) {
1090 // Set up GENA Subscriptions
1091 if (service.isRegistered(this)) {
1092 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1093 Boolean state = subscriptionState.get(subscription);
1094 if (state == null || !state) {
1095 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
1096 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
1097 subscriptionState.put(subscription, true);
1104 private void removeSubscription() {
1105 synchronized (upnpLock) {
1106 // Set up GENA Subscriptions
1107 if (service.isRegistered(this)) {
1108 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1109 Boolean state = subscriptionState.get(subscription);
1110 if (state != null && state) {
1111 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
1112 service.removeSubscription(this, subscription);
1116 subscriptionState = new HashMap<>();
1121 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
1122 if (service == null) {
1125 synchronized (upnpLock) {
1126 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
1127 subscriptionState.put(service, succeeded);
1131 private Map<String, String> executeAction(String serviceId, String actionId, @Nullable Map<String, String> inputs) {
1132 Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
1133 result.forEach((variable, value) -> {
1134 this.onValueReceived(variable, value, serviceId);
1139 private void updatePlayerState() {
1140 if (!updateZoneInfo()) {
1141 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
1142 logger.debug("Sonos player {} is not available in local network", getUDN());
1143 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1144 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
1145 synchronized (upnpLock) {
1146 subscriptionState = new HashMap<>();
1149 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
1150 logger.debug("Sonos player {} has been found in local network", getUDN());
1151 updateStatus(ThingStatus.ONLINE);
1155 protected void updateCurrentZoneName() {
1156 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_ATTRIBUTES, null);
1159 protected void updateLed() {
1160 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
1163 protected void updateTime() {
1164 executeAction(SERVICE_ALARM_CLOCK, "GetTimeNow", null);
1167 protected void updatePosition() {
1168 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_POSITION_INFO, null);
1171 protected void updateRunningAlarmProperties() {
1172 Map<String, String> result = service.invokeAction(this, SERVICE_AV_TRANSPORT,
1173 ACTION_GET_RUNNING_ALARM_PROPERTIES, null);
1175 String alarmID = result.get("AlarmID");
1176 String loggedStartTime = result.get("LoggedStartTime");
1177 String newStringValue = null;
1178 if (alarmID != null && loggedStartTime != null) {
1179 newStringValue = alarmID + " - " + loggedStartTime;
1181 newStringValue = "No running alarm";
1183 result.put("RunningAlarmProperties", newStringValue);
1185 result.forEach((variable, value) -> {
1186 this.onValueReceived(variable, value, SERVICE_AV_TRANSPORT);
1190 protected boolean updateZoneInfo() {
1191 Map<String, String> result = executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_INFO, null);
1193 Map<String, String> properties = editProperties();
1194 String value = stateMap.get("HardwareVersion");
1195 if (value != null && !value.isEmpty()) {
1196 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1198 value = stateMap.get("DisplaySoftwareVersion");
1199 if (value != null && !value.isEmpty()) {
1200 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1202 value = stateMap.get("SerialNumber");
1203 if (value != null && !value.isEmpty()) {
1204 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1206 value = stateMap.get("MACAddress");
1207 if (value != null && !value.isEmpty()) {
1208 properties.put(MAC_ADDRESS, value);
1210 value = stateMap.get("IPAddress");
1211 if (value != null && !value.isEmpty()) {
1212 properties.put(IP_ADDRESS, value);
1214 updateProperties(properties);
1216 return !result.isEmpty();
1219 public String getCoordinator() {
1220 for (SonosZoneGroup zg : getZoneGroups()) {
1221 if (zg.getMembers().contains(getUDN())) {
1222 return zg.getCoordinator();
1228 public boolean isCoordinator() {
1229 return getUDN().equals(getCoordinator());
1232 protected void updateMediaInformation() {
1233 String currentURI = getCurrentURI();
1234 SonosMetaData currentTrack = getTrackMetadata();
1235 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1237 String stationID = null;
1238 SonosMediaInformation mediaInfo = new SonosMediaInformation();
1240 // if currentURI == null, we do nothing
1241 if (currentURI != null) {
1242 if (currentURI.isEmpty()) {
1244 mediaInfo = new SonosMediaInformation(true);
1247 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1248 // The Sonos is a slave member of a group
1249 // The media information will be updated by the coordinator
1250 // Notification of group change occurs later, so we just check the URI
1252 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1253 // Radio stream (tune-in)
1254 stationID = extractStationId(currentURI);
1255 mediaInfo = SonosMediaInformation.parseTuneInMediaInfo(getOpmlData(stationID),
1256 currentUriMetaData != null ? currentUriMetaData.getTitle() : null, currentTrack);
1259 else if (isPlayingRadioApp(currentURI)) {
1260 mediaInfo = SonosMediaInformation.parseRadioAppMediaInfo(
1261 currentUriMetaData != null ? currentUriMetaData.getTitle() : null, currentTrack);
1264 else if (isPlayingLineIn(currentURI)) {
1265 mediaInfo = SonosMediaInformation.parseTrackTitle(currentTrack);
1268 else if (isPlayingRadio(currentURI)
1269 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1270 mediaInfo = SonosMediaInformation.parseTrack(currentTrack);
1274 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1275 ? currentTrack.getAlbumArtUri()
1278 ZonePlayerHandler handlerForImageUpdate = null;
1279 for (String member : getZoneGroupMembers()) {
1281 ZonePlayerHandler memberHandler = getHandlerByName(member);
1282 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1283 if (memberHandler.isLinked(CURRENTALBUMART)
1284 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1285 handlerForImageUpdate = memberHandler;
1287 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1288 SERVICE_AV_TRANSPORT);
1289 if (mediaInfo.needsUpdate()) {
1290 String artist = mediaInfo.getArtist();
1291 String album = mediaInfo.getAlbum();
1292 String title = mediaInfo.getTitle();
1293 String combinedInfo = mediaInfo.getCombinedInfo();
1294 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "",
1295 SERVICE_AV_TRANSPORT);
1296 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "",
1297 SERVICE_AV_TRANSPORT);
1298 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "",
1299 SERVICE_AV_TRANSPORT);
1300 memberHandler.onValueReceived("CurrentURIFormatted", (combinedInfo != null) ? combinedInfo : "",
1301 SERVICE_AV_TRANSPORT);
1302 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, SERVICE_AV_TRANSPORT);
1305 } catch (IllegalStateException e) {
1306 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1309 if (mediaInfo.needsUpdate() && handlerForImageUpdate != null) {
1310 handlerForImageUpdate.updateAlbumArtChannel(true);
1314 private @Nullable String getOpmlData(@Nullable String stationId) {
1315 String url = opmlUrl;
1316 if (url != null && stationId != null && !stationId.isEmpty()) {
1317 String mac = getMACAddress();
1318 if (mac != null && !mac.isEmpty()) {
1319 url = url.replace("%id", stationId);
1320 url = url.replace("%serial", mac);
1321 String response = null;
1323 response = HttpUtil.executeUrl("GET", url, HTTP_TIMEOUT);
1324 } catch (IOException e) {
1325 logger.debug("OPML request failed ({})", url, e);
1327 logger.trace("OPML response = {}", response);
1334 private @Nullable String extractStationId(String uri) {
1335 String stationID = null;
1336 if (isPlayingStream(uri)) {
1337 stationID = substringBetween(uri, ":s", "?sid");
1338 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1339 stationID = substringBetween(uri, "sid=s", "&");
1344 private @Nullable String substringBetween(String str, String open, String close) {
1345 String result = null;
1346 int idx1 = str.indexOf(open);
1348 idx1 += open.length();
1349 int idx2 = str.indexOf(close, idx1);
1351 result = str.substring(idx1, idx2);
1357 public @Nullable String getGroupCoordinatorIsLocal() {
1358 return stateMap.get("GroupCoordinatorIsLocal");
1361 public boolean isGroupCoordinator() {
1362 return "true".equals(getGroupCoordinatorIsLocal());
1366 public String getUDN() {
1367 String udn = configuration.udn;
1368 return udn != null && !udn.isEmpty() ? udn : "undefined";
1371 public @Nullable String getCurrentURI() {
1372 return stateMap.get("CurrentURI");
1375 public @Nullable String getCurrentURIMetadataAsString() {
1376 return stateMap.get("CurrentURIMetaData");
1379 public @Nullable SonosMetaData getCurrentURIMetadata() {
1380 String metaData = getCurrentURIMetadataAsString();
1381 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1384 public @Nullable SonosMetaData getTrackMetadata() {
1385 String metaData = stateMap.get("CurrentTrackMetaData");
1386 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1389 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1390 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1391 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1394 public @Nullable String getMACAddress() {
1395 String mac = stateMap.get("MACAddress");
1396 if (mac == null || mac.isEmpty()) {
1399 return stateMap.get("MACAddress");
1402 public @Nullable String getRefreshedPosition() {
1404 return stateMap.get("RelTime");
1407 public long getRefreshedCurrenTrackNr() {
1409 String value = stateMap.get("Track");
1410 if (value != null) {
1411 return Long.valueOf(value);
1417 public @Nullable String getVolume() {
1418 return stateMap.get("VolumeMaster");
1421 public boolean isOutputLevelFixed() {
1422 return "1".equals(stateMap.get("OutputFixed"));
1425 public @Nullable String getBass() {
1426 return stateMap.get("Bass");
1429 public @Nullable String getTreble() {
1430 return stateMap.get("Treble");
1433 public @Nullable String getLoudness() {
1434 return stateMap.get("LoudnessMaster");
1437 public @Nullable String getSurroundEnabled() {
1438 return stateMap.get("SurroundEnabled");
1441 public @Nullable String getSurroundMusicMode() {
1442 return stateMap.get("SurroundMode");
1445 public @Nullable String getSurroundTvLevel() {
1446 return stateMap.get("SurroundLevel");
1449 public @Nullable String getSurroundMusicLevel() {
1450 return stateMap.get("MusicSurroundLevel");
1453 public @Nullable String getCodec() {
1454 String codec = stateMap.get("HTAudioIn");
1455 if (codec != null) {
1471 codec = "dolbyAtmos";
1489 codec = "Unknown - " + codec;
1495 public @Nullable String getSubwooferEnabled() {
1496 return stateMap.get("SubEnabled");
1499 public @Nullable String getSubwooferGain() {
1500 return stateMap.get("SubGain");
1503 public @Nullable String getHeightLevel() {
1504 return stateMap.get("HeightChannelLevel");
1507 public @Nullable String getTransportState() {
1508 return stateMap.get("TransportState");
1511 public @Nullable String getCurrentTitle() {
1512 return stateMap.get("CurrentTitle");
1515 public @Nullable String getCurrentArtist() {
1516 return stateMap.get("CurrentArtist");
1519 public @Nullable String getCurrentAlbum() {
1520 return stateMap.get("CurrentAlbum");
1523 public List<SonosEntry> getArtists(String filter) {
1524 return getEntries("A:", filter);
1527 public List<SonosEntry> getArtists() {
1528 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1531 public List<SonosEntry> getAlbums(String filter) {
1532 return getEntries("A:ALBUM", filter);
1535 public List<SonosEntry> getAlbums() {
1536 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1539 public List<SonosEntry> getTracks(String filter) {
1540 return getEntries("A:TRACKS", filter);
1543 public List<SonosEntry> getTracks() {
1544 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1547 public List<SonosEntry> getQueue(String filter) {
1548 return getEntries("Q:0", filter);
1551 public List<SonosEntry> getQueue() {
1552 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1555 public long getQueueSize() {
1556 return getNbEntries("Q:0");
1559 public List<SonosEntry> getPlayLists(String filter) {
1560 return getEntries("SQ:", filter);
1563 public List<SonosEntry> getPlayLists() {
1564 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1567 public List<SonosEntry> getFavoriteRadios(String filter) {
1568 return getEntries("R:0/0", filter);
1571 public List<SonosEntry> getFavoriteRadios() {
1572 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1576 * Searches for entries in the 'favorites' list on a sonos account
1580 public List<SonosEntry> getFavorites() {
1581 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1584 protected List<SonosEntry> getEntries(String type, String filter) {
1587 Map<String, String> inputs = new HashMap<>();
1588 inputs.put("ObjectID", type);
1589 inputs.put("BrowseFlag", "BrowseDirectChildren");
1590 inputs.put("Filter", filter);
1591 inputs.put("StartingIndex", Long.toString(startAt));
1592 inputs.put("RequestedCount", Integer.toString(200));
1593 inputs.put("SortCriteria", "");
1595 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1597 String initialResult = result.get("Result");
1598 if (initialResult == null) {
1599 return Collections.emptyList();
1602 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1603 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1605 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1606 startAt = startAt + initialNumberReturned;
1608 while (startAt < totalMatches) {
1609 inputs.put("StartingIndex", Long.toString(startAt));
1610 result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1612 // Execute this action synchronously
1613 String nextResult = result.get("Result");
1614 if (nextResult == null) {
1618 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1620 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1622 startAt = startAt + numberReturned;
1628 protected long getNbEntries(String type) {
1629 Map<String, String> inputs = new HashMap<>();
1630 inputs.put("ObjectID", type);
1631 inputs.put("BrowseFlag", "BrowseDirectChildren");
1632 inputs.put("Filter", "dc:title");
1633 inputs.put("StartingIndex", "0");
1634 inputs.put("RequestedCount", "1");
1635 inputs.put("SortCriteria", "");
1637 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1639 return getResultEntry(result, "TotalMatches", type, "dc:title");
1643 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1645 * @param resultInput - the map to be examined for the requestedKey
1646 * @param requestedKey - the key to be sought in the resultInput map
1647 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1648 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1650 * @return 0 as long or the value corresponding to the requiredKey if found
1652 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1653 String entriesFilter) {
1656 if (resultInput.isEmpty()) {
1661 String resultString = resultInput.get(requestedKey);
1662 if (resultString == null) {
1663 throw new NumberFormatException("Requested key is null.");
1665 result = Long.valueOf(resultString);
1666 } catch (NumberFormatException ex) {
1667 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1668 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1675 * Save the state (track, position etc) of the Sonos Zone player.
1677 * @return true if no error occurred.
1679 protected void saveState() {
1680 synchronized (stateLock) {
1681 savedState = new SonosZonePlayerState();
1682 String currentURI = getCurrentURI();
1684 savedState.transportState = getTransportState();
1685 savedState.volume = getVolume();
1687 if (currentURI != null) {
1688 if (isPlayingStreamOrRadio(currentURI)) {
1689 // we are streaming music, like tune-in radio or Google Play Music radio
1690 SonosMetaData track = getTrackMetadata();
1691 SonosMetaData current = getCurrentURIMetadata();
1692 if (track != null && current != null) {
1693 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1694 current.getUpnpClass(), currentURI);
1696 } else if (currentURI.contains(GROUP_URI)) {
1697 // we are a slave to some coordinator
1698 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1699 } else if (isPlayingLineIn(currentURI)) {
1700 // we are streaming from the Line In connection
1701 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1702 } else if (isPlayingQueue(currentURI)) {
1703 // we are playing something that sits in the queue
1704 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1705 if (queued != null) {
1706 savedState.track = getRefreshedCurrenTrackNr();
1708 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1709 // we are playing a real 'saved' playlist
1710 List<SonosEntry> playLists = getPlayLists();
1711 for (SonosEntry someList : playLists) {
1712 if (someList.getTitle().equals(queued.getTitle())) {
1713 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1714 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1719 } else if (queued.getUpnpClass().contains("object.container")) {
1720 // we are playing some other sort of
1721 // 'container' - we will save that to a
1722 // playlist for our convenience
1723 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1725 // save the playlist
1726 String existingList = "";
1727 List<SonosEntry> playLists = getPlayLists();
1728 for (SonosEntry someList : playLists) {
1729 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1730 existingList = someList.getId();
1735 saveQueue(TITLE_PREFIX + getUDN(), existingList);
1737 // get all the playlists and a ref to our
1739 playLists = getPlayLists();
1740 for (SonosEntry someList : playLists) {
1741 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1742 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1743 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1750 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1754 savedState.relTime = getRefreshedPosition();
1756 savedState.entry = null;
1762 * Restore the state (track, position etc) of the Sonos Zone player.
1764 * @return true if no error occurred.
1766 protected void restoreState() {
1767 synchronized (stateLock) {
1768 SonosZonePlayerState state = savedState;
1769 if (state != null) {
1770 // put settings back
1771 String volume = state.volume;
1772 if (volume != null) {
1773 setVolume(DecimalType.valueOf(volume));
1776 if (isCoordinator()) {
1777 SonosEntry entry = state.entry;
1778 if (entry != null) {
1779 // check if we have a playlist to deal with
1780 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1781 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1782 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1783 setCurrentURI(entry);
1784 setPositionTrack(state.track);
1786 setCurrentURI(entry);
1787 setPosition(state.relTime);
1791 String transportState = state.transportState;
1792 if (STATE_PLAYING.equals(transportState)) {
1794 } else if (STATE_STOPPED.equals(transportState)) {
1796 } else if (STATE_PAUSED_PLAYBACK.equals(transportState)) {
1804 public void saveQueue(String name, String queueID) {
1805 executeAction(SERVICE_AV_TRANSPORT, ACTION_SAVE_QUEUE, Map.of("Title", name, "ObjectID", queueID));
1808 public void setVolume(Command command) {
1809 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1810 || command instanceof PercentType) {
1811 String newValue = null;
1812 String currentVolume = getVolume();
1813 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1814 int i = Integer.valueOf(currentVolume);
1815 newValue = String.valueOf(Math.min(100, i + 1));
1816 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1817 int i = Integer.valueOf(currentVolume);
1818 newValue = String.valueOf(Math.max(0, i - 1));
1819 } else if (command == OnOffType.ON) {
1821 } else if (command == OnOffType.OFF) {
1823 } else if (command instanceof DecimalType) {
1824 newValue = String.valueOf(((DecimalType) command).intValue());
1828 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_VOLUME,
1829 Map.of("Channel", "Master", "DesiredVolume", newValue));
1834 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1835 * AdHoc groups handles the volume specifically for each player.
1836 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1838 public void setVolumeForGroup(Command command) {
1839 if (isAdHocGroup() || isStandalonePlayer()) {
1843 getCoordinatorHandler().setVolume(command);
1844 } catch (IllegalStateException e) {
1845 logger.debug("Cannot set group volume ({})", e.getMessage());
1850 public void setBass(Command command) {
1851 if (!isOutputLevelFixed()) {
1852 String newValue = getNewNumericValue(command, getBass(), MIN_BASS, MAX_BASS);
1853 if (newValue != null) {
1854 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_BASS,
1855 Map.of("InstanceID", "0", "DesiredBass", newValue));
1860 public void setTreble(Command command) {
1861 if (!isOutputLevelFixed()) {
1862 String newValue = getNewNumericValue(command, getTreble(), MIN_TREBLE, MAX_TREBLE);
1863 if (newValue != null) {
1864 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_TREBLE,
1865 Map.of("InstanceID", "0", "DesiredTreble", newValue));
1870 private @Nullable String getNewNumericValue(Command command, @Nullable String currentValue, int minValue,
1872 String newValue = null;
1873 if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1874 if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1875 int i = Integer.valueOf(currentValue);
1876 newValue = String.valueOf(Math.min(maxValue, i + 1));
1877 } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1878 int i = Integer.valueOf(currentValue);
1879 newValue = String.valueOf(Math.max(minValue, i - 1));
1880 } else if (command instanceof DecimalType) {
1881 newValue = String.valueOf(((DecimalType) command).intValue());
1887 public void setLoudness(Command command) {
1888 if (!isOutputLevelFixed() && (command instanceof OnOffType || command instanceof OpenClosedType
1889 || command instanceof UpDownType)) {
1890 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1891 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
1892 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_LOUDNESS,
1893 Map.of("InstanceID", "0", "Channel", "Master", "DesiredLoudness", value));
1898 * Checks if the player receiving the command is part of a group that
1899 * consists of randomly added players or contains bonded players
1903 private boolean isAdHocGroup() {
1904 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1905 if (currentZoneGroup != null) {
1906 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1908 for (String zoneName : zoneGroupMemberNames) {
1909 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1910 // At least one "ZoneName" differs so we have an AdHoc group
1919 * Checks if the player receiving the command is a standalone player
1923 private boolean isStandalonePlayer() {
1924 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1925 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1928 private Collection<SonosZoneGroup> getZoneGroups() {
1929 String zoneGroupState = stateMap.get("ZoneGroupState");
1930 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1934 * Returns the current zone group
1935 * (of which the player receiving the command is part)
1937 * @return {@link SonosZoneGroup}
1939 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1940 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1941 if (zoneGroup.getMembers().contains(getUDN())) {
1945 logger.debug("Could not fetch Sonos group state information");
1950 * Sets the volume level for a notification sound
1952 * @param notificationSoundVolume
1954 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1955 if (notificationSoundVolume != null) {
1956 setVolumeForGroup(notificationSoundVolume);
1961 * Gets the volume level for a notification sound
1963 public @Nullable PercentType getNotificationSoundVolume() {
1964 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1965 if (notificationSoundVolume == null) {
1966 // if no value is set we use the current volume instead
1967 String volume = getVolume();
1968 return volume != null ? new PercentType(volume) : null;
1970 return new PercentType(notificationSoundVolume);
1973 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1974 Map<String, String> inputs = new HashMap<>();
1977 inputs.put("InstanceID", "0");
1978 inputs.put("EnqueuedURI", URI);
1979 inputs.put("EnqueuedURIMetaData", meta);
1980 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1981 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1982 } catch (NumberFormatException ex) {
1983 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1986 executeAction(SERVICE_AV_TRANSPORT, ACTION_ADD_URI_TO_QUEUE, inputs);
1989 public void setCurrentURI(SonosEntry newEntry) {
1990 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1993 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1994 if (URI != null && URIMetaData != null) {
1995 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1996 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_AV_TRANSPORT_URI,
1997 Map.of("InstanceID", "0", "CurrentURI", URI, "CurrentURIMetaData", URIMetaData));
2001 public void setPosition(@Nullable String relTime) {
2002 seek("REL_TIME", relTime);
2005 public void setPositionTrack(long tracknr) {
2006 seek("TRACK_NR", Long.toString(tracknr));
2009 public void setPositionTrack(String tracknr) {
2010 seek("TRACK_NR", tracknr);
2013 protected void seek(String unit, @Nullable String target) {
2014 if (target != null) {
2015 executeAction(SERVICE_AV_TRANSPORT, ACTION_SEEK, Map.of("InstanceID", "0", "Unit", unit, "Target", target));
2019 public void play() {
2020 executeAction(SERVICE_AV_TRANSPORT, ACTION_PLAY, Map.of("Speed", "1"));
2023 public void stop() {
2024 executeAction(SERVICE_AV_TRANSPORT, ACTION_STOP, null);
2027 public void pause() {
2028 executeAction(SERVICE_AV_TRANSPORT, ACTION_PAUSE, null);
2031 public void setShuffle(Command command) {
2032 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2034 ZonePlayerHandler coordinator = getCoordinatorHandler();
2036 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2037 || command.equals(OpenClosedType.OPEN)) {
2038 switch (coordinator.getRepeatMode()) {
2040 coordinator.updatePlayMode("SHUFFLE");
2043 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
2046 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
2049 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2050 || command.equals(OpenClosedType.CLOSED)) {
2051 switch (coordinator.getRepeatMode()) {
2053 coordinator.updatePlayMode("REPEAT_ALL");
2056 coordinator.updatePlayMode("REPEAT_ONE");
2059 coordinator.updatePlayMode("NORMAL");
2063 } catch (IllegalStateException e) {
2064 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
2069 public void setRepeat(Command command) {
2070 if (command instanceof StringType) {
2072 ZonePlayerHandler coordinator = getCoordinatorHandler();
2074 switch (command.toString()) {
2076 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
2079 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
2082 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
2085 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
2086 command.toString());
2089 } catch (IllegalStateException e) {
2090 logger.debug("Cannot handle repeat command ({})", e.getMessage());
2095 public void setSubwoofer(Command command) {
2096 setEqualizerBooleanSetting(command, "SubEnable");
2099 public void setSubwooferGain(Command command) {
2100 setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
2103 public void setSurround(Command command) {
2104 setEqualizerBooleanSetting(command, "SurroundEnable");
2107 public void setSurroundMusicMode(Command command) {
2108 if (command instanceof StringType) {
2109 setEQ("SurroundMode", command.toString());
2113 public void setSurroundMusicLevel(Command command) {
2114 setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
2115 MAX_SURROUND_LEVEL);
2118 public void setSurroundTvLevel(Command command) {
2119 setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
2120 MAX_SURROUND_LEVEL);
2123 public void setHeightLevel(Command command) {
2124 setEqualizerNumericSetting(command, "HeightChannelLevel", getHeightLevel(), MIN_HEIGHT_LEVEL, MAX_HEIGHT_LEVEL);
2127 public void setNightMode(Command command) {
2128 setEqualizerBooleanSetting(command, "NightMode");
2131 public void setSpeechEnhancement(Command command) {
2132 setEqualizerBooleanSetting(command, "DialogLevel");
2135 private void setEqualizerBooleanSetting(Command command, String eqType) {
2136 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2137 setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2138 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
2142 private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
2144 String newValue = getNewNumericValue(command, currentValue, minValue, maxValue);
2145 if (newValue != null) {
2146 setEQ(eqType, newValue);
2150 private void setEQ(String eqType, String value) {
2152 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_EQ,
2153 Map.of("InstanceID", "0", "EQType", eqType, "DesiredValue", value));
2154 } catch (IllegalStateException e) {
2155 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2159 public @Nullable String getNightMode() {
2160 return stateMap.get("NightMode");
2163 public @Nullable String getDialogLevel() {
2164 return stateMap.get("DialogLevel");
2167 public @Nullable String getPlayMode() {
2168 return stateMap.get("CurrentPlayMode");
2171 public Boolean isShuffleActive() {
2172 String playMode = getPlayMode();
2173 return (playMode != null && playMode.startsWith("SHUFFLE"));
2176 public String getRepeatMode() {
2177 String mode = "OFF";
2178 String playMode = getPlayMode();
2179 if (playMode != null) {
2186 case "SHUFFLE_REPEAT_ONE":
2190 case "SHUFFLE_NOREPEAT":
2199 public @Nullable String getMicEnabled() {
2200 return stateMap.get("MicEnabled");
2203 protected void updatePlayMode(String playMode) {
2204 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_PLAY_MODE, Map.of("InstanceID", "0", "NewPlayMode", playMode));
2208 * Clear all scheduled music from the current queue.
2211 public void removeAllTracksFromQueue() {
2212 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE, Map.of("InstanceID", "0"));
2216 * Play music from the line-in of the given Player referenced by the given UDN or name
2218 * @param udn or name
2220 public void playLineIn(Command command) {
2221 if (command instanceof StringType) {
2223 LineInType lineInType = LineInType.ANY;
2224 String remotePlayerName = command.toString();
2225 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2226 lineInType = LineInType.ANALOG;
2227 remotePlayerName = remotePlayerName.substring(7);
2228 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2229 lineInType = LineInType.DIGITAL;
2230 remotePlayerName = remotePlayerName.substring(8);
2232 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2233 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2235 // check if player has a line-in connected
2236 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2237 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2238 // stop whatever is currently playing
2239 coordinatorHandler.stop();
2242 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2243 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2245 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2248 // take the system off mute
2249 coordinatorHandler.setMute(OnOffType.OFF);
2252 coordinatorHandler.play();
2254 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2256 } catch (IllegalStateException e) {
2257 logger.debug("Cannot play line-in ({})", e.getMessage());
2262 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2263 ZonePlayerHandler handler = coordinatorHandler;
2264 if (handler != null) {
2268 handler = getHandlerByName(getCoordinator());
2269 coordinatorHandler = handler;
2271 } catch (IllegalStateException e) {
2272 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2277 * Returns a list of all zone group members this particular player is member of
2278 * Or empty list if the players is not assigned to any group
2280 * @return a list of Strings containing the UDNs of other group members
2282 protected List<String> getZoneGroupMembers() {
2283 List<String> result = new ArrayList<>();
2285 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2286 if (!zoneGroups.isEmpty()) {
2287 for (SonosZoneGroup zg : zoneGroups) {
2288 if (zg.getMembers().contains(getUDN())) {
2289 result.addAll(zg.getMembers());
2294 // If the group topology was not yet received, return at least the current Sonos zone
2295 result.add(getUDN());
2301 * Returns a list of other zone group members this particular player is member of
2302 * Or empty list if the players is not assigned to any group
2304 * @return a list of Strings containing the UDNs of other group members
2306 protected List<String> getOtherZoneGroupMembers() {
2307 List<String> zoneGroupMembers = getZoneGroupMembers();
2308 zoneGroupMembers.remove(getUDN());
2309 return zoneGroupMembers;
2312 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2313 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2314 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2315 if (thing != null) {
2316 ThingHandler handler = thing.getHandler();
2317 if (handler instanceof ZonePlayerHandler) {
2318 return (ZonePlayerHandler) handler;
2322 for (Thing aThing : localThingRegistry.getAll()) {
2323 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2324 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2325 ThingHandler handler = aThing.getHandler();
2326 if (handler instanceof ZonePlayerHandler) {
2327 return (ZonePlayerHandler) handler;
2331 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2334 public void setMute(Command command) {
2335 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2336 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2337 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
2338 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_MUTE,
2339 Map.of("Channel", "Master", "DesiredMute", value));
2343 public List<SonosAlarm> getCurrentAlarmList() {
2344 Map<String, String> result = executeAction(SERVICE_ALARM_CLOCK, "ListAlarms", null);
2345 String alarmList = result.get("CurrentAlarmList");
2346 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2349 public void updateAlarm(SonosAlarm alarm) {
2350 Map<String, String> inputs = new HashMap<>();
2353 inputs.put("ID", Integer.toString(alarm.getId()));
2354 inputs.put("StartLocalTime", alarm.getStartTime());
2355 inputs.put("Duration", alarm.getDuration());
2356 inputs.put("Recurrence", alarm.getRecurrence());
2357 inputs.put("RoomUUID", alarm.getRoomUUID());
2358 inputs.put("ProgramURI", alarm.getProgramURI());
2359 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2360 inputs.put("PlayMode", alarm.getPlayMode());
2361 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2362 if (alarm.getIncludeLinkedZones()) {
2363 inputs.put("IncludeLinkedZones", "1");
2365 inputs.put("IncludeLinkedZones", "0");
2368 if (alarm.getEnabled()) {
2369 inputs.put("Enabled", "1");
2371 inputs.put("Enabled", "0");
2373 } catch (NumberFormatException ex) {
2374 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2377 executeAction(SERVICE_ALARM_CLOCK, "UpdateAlarm", inputs);
2380 public void setAlarm(Command command) {
2381 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2382 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2384 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2385 || command.equals(OpenClosedType.CLOSED)) {
2391 public void setAlarm(boolean alarmSwitch) {
2392 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2394 // find the nearest alarm - take the current time from the Sonos system,
2395 // not the system where we are running
2396 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2397 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2399 String currentLocalTime = getTime();
2400 Date currentDateTime = null;
2402 currentDateTime = fmt.parse(currentLocalTime);
2403 } catch (ParseException e) {
2404 logger.debug("An exception occurred while formatting a date", e);
2407 if (currentDateTime != null) {
2408 Calendar currentDateTimeCalendar = Calendar.getInstance();
2409 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2410 currentDateTimeCalendar.setTime(currentDateTime);
2411 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2412 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2414 SonosAlarm firstAlarm = null;
2416 for (SonosAlarm anAlarm : sonosAlarms) {
2417 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2418 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2421 durationDate = durationFormat.parse(anAlarm.getDuration());
2422 } catch (ParseException e) {
2423 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2427 long duration = durationDate.getTime();
2429 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2430 shortestDuration = duration;
2431 firstAlarm = anAlarm;
2436 if (firstAlarm != null) {
2438 firstAlarm.setEnabled(true);
2440 firstAlarm.setEnabled(false);
2443 updateAlarm(firstAlarm);
2448 public @Nullable String getTime() {
2450 return stateMap.get("CurrentLocalTime");
2453 public @Nullable String getAlarmRunning() {
2454 return stateMap.get("AlarmRunning");
2457 public boolean isAlarmRunning() {
2458 return "1".equals(getAlarmRunning());
2461 public void snoozeAlarm(Command command) {
2462 if (isAlarmRunning() && command instanceof DecimalType) {
2463 int minutes = ((DecimalType) command).intValue();
2465 Map<String, String> inputs = new HashMap<>();
2467 Calendar snoozePeriod = Calendar.getInstance();
2468 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2469 snoozePeriod.setTimeInMillis(0);
2470 snoozePeriod.add(Calendar.MINUTE, minutes);
2471 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2472 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2475 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2476 } catch (NumberFormatException ex) {
2477 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2480 executeAction(SERVICE_AV_TRANSPORT, ACTION_SNOOZE_ALARM, inputs);
2482 logger.debug("There is no alarm running on {}", getUDN());
2486 public @Nullable String getAnalogLineInConnected() {
2487 return stateMap.get(LINEINCONNECTED);
2490 public boolean isAnalogLineInConnected() {
2491 return "true".equals(getAnalogLineInConnected());
2494 public @Nullable String getOpticalLineInConnected() {
2495 return stateMap.get(TOSLINEINCONNECTED);
2498 public boolean isOpticalLineInConnected() {
2499 return "true".equals(getOpticalLineInConnected());
2502 public void becomeStandAlonePlayer() {
2503 executeAction(SERVICE_AV_TRANSPORT, ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP, null);
2506 public void addMember(Command command) {
2507 if (command instanceof StringType) {
2508 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2510 getHandlerByName(command.toString()).setCurrentURI(entry);
2511 } catch (IllegalStateException e) {
2512 logger.debug("Cannot add group member ({})", e.getMessage());
2517 @SuppressWarnings("PMD.CompareObjectsWithEquals")
2518 public boolean publicAddress(LineInType lineInType) {
2519 // check if sourcePlayer has a line-in connected
2520 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2521 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2522 // first remove this player from its own group if any
2523 becomeStandAlonePlayer();
2525 // add all other players to this new group
2526 for (SonosZoneGroup group : getZoneGroups()) {
2527 for (String player : group.getMembers()) {
2529 ZonePlayerHandler somePlayer = getHandlerByName(player);
2530 if (somePlayer != this) {
2531 somePlayer.becomeStandAlonePlayer();
2533 addMember(StringType.valueOf(somePlayer.getUDN()));
2535 } catch (IllegalStateException e) {
2536 logger.debug("Cannot add to group ({})", e.getMessage());
2542 ZonePlayerHandler coordinator = getCoordinatorHandler();
2543 // set the URI of the group to the line-in
2544 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2545 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2546 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2548 coordinator.setCurrentURI(entry);
2552 } catch (IllegalStateException e) {
2553 logger.debug("Cannot handle command ({})", e.getMessage());
2557 logger.debug("Line-in of {} is not connected", getUDN());
2563 * Play a given url to music in one of the music libraries.
2566 * in the format of //host/folder/filename.mp3
2568 public void playURI(Command command) {
2569 if (command instanceof StringType) {
2571 String url = command.toString();
2573 ZonePlayerHandler coordinator = getCoordinatorHandler();
2575 // stop whatever is currently playing
2577 coordinator.waitForNotTransportState(STATE_PLAYING);
2579 // clear any tracks which are pending in the queue
2580 coordinator.removeAllTracksFromQueue();
2582 // add the new track we want to play to the queue
2583 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2584 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2585 // default to file based url
2586 url = FILE_URI + url;
2588 coordinator.addURIToQueue(url, "", 0, true);
2590 // set the current playlist to our new queue
2591 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2593 // take the system off mute
2594 coordinator.setMute(OnOffType.OFF);
2598 } catch (IllegalStateException e) {
2599 logger.debug("Cannot play URI ({})", e.getMessage());
2600 } catch (InterruptedException e) {
2601 logger.debug("Play URI interrupted ({})", e.getMessage());
2602 Thread.currentThread().interrupt();
2607 private void scheduleNotificationSound(final Command command) {
2608 scheduler.submit(() -> {
2609 synchronized (notificationLock) {
2610 playNotificationSoundURI(command);
2616 * Play a given notification sound
2618 * @param url in the format of //host/folder/filename.mp3
2620 public void playNotificationSoundURI(Command notificationURL) {
2621 if (notificationURL instanceof StringType) {
2623 ZonePlayerHandler coordinator = getCoordinatorHandler();
2625 String currentURI = coordinator.getCurrentURI();
2626 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2627 coordinator.getCurrentURIMetadataAsString());
2629 if (isPlayingStreamOrRadio(currentURI)) {
2630 handleNotifForRadioStream(currentURI, notificationURL, coordinator);
2631 } else if (isPlayingLineIn(currentURI)) {
2632 handleNotifForLineIn(currentURI, notificationURL, coordinator);
2633 } else if (isPlayingVirtualLineIn(currentURI)) {
2634 handleNotifForVirtualLineIn(currentURI, notificationURL, coordinator);
2635 } else if (isPlayingQueue(currentURI)) {
2636 handleNotifForSharedQueue(currentURI, notificationURL, coordinator);
2637 } else if (isPlaylistEmpty(coordinator)) {
2638 handleNotifForEmptyQueue(notificationURL, coordinator);
2640 logger.debug("Notification feature not yet implemented while the current media is being played");
2642 synchronized (notificationLock) {
2643 notificationLock.notify();
2645 } catch (IllegalStateException e) {
2646 logger.debug("Cannot play notification sound ({})", e.getMessage());
2647 } catch (InterruptedException e) {
2648 logger.debug("Play notification sound interrupted ({})", e.getMessage());
2649 Thread.currentThread().interrupt();
2654 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2655 return coordinator.getQueueSize() == 0;
2658 private boolean isPlayingQueue(@Nullable String currentURI) {
2659 return currentURI != null && currentURI.contains(QUEUE_URI);
2662 private boolean isPlayingStream(@Nullable String currentURI) {
2663 return currentURI != null && currentURI.contains(STREAM_URI);
2666 private boolean isPlayingRadio(@Nullable String currentURI) {
2667 // Google Play Music radio or Apple Music radio
2668 return currentURI != null && currentURI.contains(RADIO_URI);
2671 private boolean isPlayingRadioApp(@Nullable String currentURI) {
2672 // RadioApp music service
2673 return currentURI != null && currentURI.contains(RADIOAPP_URI);
2676 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2677 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2680 private boolean isPlayingStreamOrRadio(@Nullable String currentURI) {
2681 return isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2682 || isPlayingRadio(currentURI) || isPlayingRadioApp(currentURI);
2685 private boolean isPlayingLineIn(@Nullable String currentURI) {
2686 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2689 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2690 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2693 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2694 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2697 private boolean isPlayingVirtualLineIn(@Nullable String currentURI) {
2698 return currentURI != null && currentURI.startsWith(VIRTUAL_LINE_IN_URI);
2702 * Does a chain of predefined actions when a Notification sound is played by
2703 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2704 * radio streaming is currently loaded
2706 * @param currentStreamURI - the currently loaded stream's URI
2707 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2708 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2709 * @throws InterruptedException
2711 private void handleNotifForRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2712 ZonePlayerHandler coordinator) throws InterruptedException {
2713 String nextAction = coordinator.getTransportState();
2714 SonosMetaData track = coordinator.getTrackMetadata();
2715 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2717 handleNotificationSound(notificationURL, coordinator);
2718 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2719 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2720 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2721 restoreLastTransportState(coordinator, nextAction);
2726 * Does a chain of predefined actions when a Notification sound is played by
2727 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2728 * line in is currently loaded
2730 * @param currentLineInURI - the currently loaded line-in URI
2731 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2732 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2733 * @throws InterruptedException
2735 private void handleNotifForLineIn(@Nullable String currentLineInURI, Command notificationURL,
2736 ZonePlayerHandler coordinator) throws InterruptedException {
2737 logger.debug("Handling notification while sound from line-in was being played");
2738 String nextAction = coordinator.getTransportState();
2740 handleNotificationSound(notificationURL, coordinator);
2741 if (currentLineInURI != null) {
2742 logger.debug("Restoring sound from line-in using URI {}", currentLineInURI);
2743 coordinator.setCurrentURI(currentLineInURI, "");
2744 restoreLastTransportState(coordinator, nextAction);
2749 * Does a chain of predefined actions when a Notification sound is played by
2750 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2751 * virtual line in is currently loaded
2753 * @param currentVirtualLineInURI - the currently loaded virtual line-in URI
2754 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2755 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2756 * @throws InterruptedException
2758 private void handleNotifForVirtualLineIn(@Nullable String currentVirtualLineInURI, Command notificationURL,
2759 ZonePlayerHandler coordinator) throws InterruptedException {
2760 logger.debug("Handling notification while sound from virtual line-in was being played");
2761 String nextAction = coordinator.getTransportState();
2762 String currentUriMetaData = coordinator.getCurrentURIMetadataAsString();
2764 handleNotificationSound(notificationURL, coordinator);
2765 if (currentVirtualLineInURI != null && currentUriMetaData != null) {
2766 logger.debug("Restoring sound from virtual line-in using URI {} and metadata {}", currentVirtualLineInURI,
2767 currentUriMetaData);
2768 coordinator.setCurrentURI(currentVirtualLineInURI, currentUriMetaData);
2769 restoreLastTransportState(coordinator, nextAction);
2774 * Does a chain of predefined actions when a Notification sound is played by
2775 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2776 * shared queue is currently loaded
2778 * @param currentQueueURI - the currently loaded queue URI
2779 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2780 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2781 * @throws InterruptedException
2783 private void handleNotifForSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2784 ZonePlayerHandler coordinator) throws InterruptedException {
2785 String nextAction = coordinator.getTransportState();
2786 String trackPosition = coordinator.getRefreshedPosition();
2787 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2789 "Handling notification while playing queue: currentQueueURI {} trackPosition {} currentTrackNumber {}",
2790 currentQueueURI, trackPosition, currentTrackNumber);
2792 handleNotificationSound(notificationURL, coordinator);
2793 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2794 if (queueUri.equals(currentQueueURI)) {
2795 coordinator.setPositionTrack(currentTrackNumber);
2796 coordinator.setPosition(trackPosition);
2797 restoreLastTransportState(coordinator, nextAction);
2802 * Handle the execution of the notification sound by sequentially executing the required steps.
2804 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2805 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2806 * @throws InterruptedException
2808 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator)
2809 throws InterruptedException {
2810 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2811 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2812 if (sourceStoppable) {
2814 coordinator.waitForNotTransportState(STATE_PLAYING);
2815 applyNotificationSoundVolume();
2817 long notificationPosition = coordinator.getQueueSize() + 1;
2818 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2819 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2820 coordinator.setPositionTrack(notificationPosition);
2821 if (!sourceStoppable) {
2823 coordinator.waitForNotTransportState(STATE_PLAYING);
2824 applyNotificationSoundVolume();
2827 coordinator.waitForFinishedNotification();
2828 if (originalVolume != null) {
2829 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2831 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2834 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction)
2835 throws InterruptedException {
2836 if (nextAction != null) {
2837 switch (nextAction) {
2840 coordinator.waitForTransportState(STATE_PLAYING);
2842 case STATE_PAUSED_PLAYBACK:
2843 coordinator.pause();
2850 * Does a chain of predefined actions when a Notification sound is played by
2851 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2852 * empty queue is currently loaded
2854 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2855 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2856 * @throws InterruptedException
2858 private void handleNotifForEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator)
2859 throws InterruptedException {
2860 String originalVolume = coordinator.getVolume();
2861 coordinator.applyNotificationSoundVolume();
2862 coordinator.playURI(notificationURL);
2863 coordinator.waitForFinishedNotification();
2864 coordinator.removeAllTracksFromQueue();
2865 if (originalVolume != null) {
2866 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2871 * Applies the notification sound volume level to the group (if not null)
2873 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2875 private void applyNotificationSoundVolume() {
2876 setNotificationSoundVolume(getNotificationSoundVolume());
2879 private void waitForFinishedNotification() throws InterruptedException {
2880 waitForTransportState(STATE_PLAYING);
2882 // check Sonos state events to determine the end of the notification sound
2883 String notificationTitle = getCurrentTitle();
2884 long playstart = System.currentTimeMillis();
2885 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2887 String currentTitle = getCurrentTitle();
2888 if ((notificationTitle == null && currentTitle != null)
2889 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2890 || !STATE_PLAYING.equals(getTransportState())) {
2896 private void waitForTransportState(String state) throws InterruptedException {
2897 if (getTransportState() != null) {
2898 long start = System.currentTimeMillis();
2899 while (!state.equals(getTransportState())) {
2901 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2908 private void waitForNotTransportState(String state) throws InterruptedException {
2909 if (getTransportState() != null) {
2910 long start = System.currentTimeMillis();
2911 while (state.equals(getTransportState())) {
2913 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2921 * Removes a range of tracks from the queue.
2922 * (<x,y> will remove y songs started by the song number x)
2924 * @param command - must be in the format <startIndex, numberOfSongs>
2926 public void removeRangeOfTracksFromQueue(Command command) {
2927 if (command instanceof StringType) {
2928 String[] rangeInputSplit = command.toString().split(",");
2929 // If range input is incorrect, remove the first song by default
2930 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2931 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2932 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE,
2933 Map.of("InstanceID", "0", "StartingIndex", startIndex, "NumberOfTracks", numberOfTracks));
2937 public void clearQueue() {
2939 ZonePlayerHandler coordinator = getCoordinatorHandler();
2941 coordinator.removeAllTracksFromQueue();
2942 } catch (IllegalStateException e) {
2943 logger.debug("Cannot clear queue ({})", e.getMessage());
2947 public void playQueue() {
2949 ZonePlayerHandler coordinator = getCoordinatorHandler();
2951 // set the current playlist to our new queue
2952 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2954 // take the system off mute
2955 coordinator.setMute(OnOffType.OFF);
2959 } catch (IllegalStateException e) {
2960 logger.debug("Cannot play queue ({})", e.getMessage());
2964 public void setLed(Command command) {
2965 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2966 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2967 || command.equals(OpenClosedType.OPEN)) ? "On" : "Off";
2968 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_SET_LED_STATE, Map.of("DesiredLEDState", value));
2969 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
2973 public void removeMember(Command command) {
2974 if (command instanceof StringType) {
2976 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2978 oldmemberHandler.becomeStandAlonePlayer();
2979 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2980 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2981 oldmemberHandler.setCurrentURI(entry);
2982 } catch (IllegalStateException e) {
2983 logger.debug("Cannot remove group member ({})", e.getMessage());
2988 public void previous() {
2989 executeAction(SERVICE_AV_TRANSPORT, ACTION_PREVIOUS, null);
2992 public void next() {
2993 executeAction(SERVICE_AV_TRANSPORT, ACTION_NEXT, null);
2996 public void stopPlaying(Command command) {
2997 if (command instanceof OnOffType) {
2999 getCoordinatorHandler().stop();
3000 } catch (IllegalStateException e) {
3001 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
3006 public void playRadio(Command command) {
3007 if (command instanceof StringType) {
3008 String station = command.toString();
3009 List<SonosEntry> stations = getFavoriteRadios();
3011 SonosEntry theEntry = null;
3012 // search for the appropriate radio based on its name (title)
3013 for (SonosEntry someStation : stations) {
3014 if (someStation.getTitle().equals(station)) {
3015 theEntry = someStation;
3020 // set the URI of the group coordinator
3021 if (theEntry != null) {
3023 ZonePlayerHandler coordinator = getCoordinatorHandler();
3024 coordinator.setCurrentURI(theEntry);
3026 } catch (IllegalStateException e) {
3027 logger.debug("Cannot play radio ({})", e.getMessage());
3030 logger.debug("Radio station '{}' not found", station);
3035 public void playTuneinStation(Command command) {
3036 if (command instanceof StringType) {
3037 String stationId = command.toString();
3038 List<SonosMusicService> allServices = getAvailableMusicServices();
3040 SonosMusicService tuneinService = null;
3041 // search for the TuneIn music service based on its name
3042 if (allServices != null) {
3043 for (SonosMusicService service : allServices) {
3044 if ("TuneIn".equals(service.getName())) {
3045 tuneinService = service;
3051 // set the URI of the group coordinator
3052 if (tuneinService != null) {
3054 ZonePlayerHandler coordinator = getCoordinatorHandler();
3055 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
3056 "object.item.audioItem.audioBroadcast",
3057 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
3058 Integer tuneinServiceType = tuneinService.getType();
3059 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
3060 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
3061 coordinator.setCurrentURI(entry);
3063 } catch (IllegalStateException e) {
3064 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
3067 logger.debug("TuneIn service not found");
3072 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
3073 if (musicServices == null) {
3074 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
3076 String serviceList = result.get("AvailableServiceDescriptorList");
3077 if (serviceList != null) {
3078 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
3079 musicServices = services;
3081 String[] servicesTypes = new String[0];
3082 String serviceTypeList = result.get("AvailableServiceTypeList");
3083 if (serviceTypeList != null) {
3084 // It is a comma separated list of service types (integers) in the same order as the services
3085 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
3087 servicesTypes = serviceTypeList.split(",");
3091 for (SonosMusicService service : services) {
3092 if (!"TuneIn".equals(service.getName())) {
3093 // Add the service type integer value from "AvailableServiceTypeList" to each service
3095 if (idx < servicesTypes.length) {
3097 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
3098 service.setType(serviceType);
3099 } catch (NumberFormatException e) {
3104 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3106 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3111 return musicServices;
3115 * This will attempt to match the station string with an entry in the
3116 * favorites list, this supports both single entries and playlists
3118 * @param favorite to match
3119 * @return true if a match was found and played.
3121 public void playFavorite(Command command) {
3122 if (command instanceof StringType) {
3123 String favorite = command.toString();
3124 List<SonosEntry> favorites = getFavorites();
3126 SonosEntry theEntry = null;
3127 // search for the appropriate favorite based on its name (title)
3128 for (SonosEntry entry : favorites) {
3129 if (entry.getTitle().equals(favorite)) {
3135 // set the URI of the group coordinator
3136 if (theEntry != null) {
3138 ZonePlayerHandler coordinator = getCoordinatorHandler();
3141 * If this is a playlist we need to treat it as such
3143 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3144 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3145 coordinator.removeAllTracksFromQueue();
3146 coordinator.addURIToQueue(theEntry);
3147 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3148 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3149 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3151 coordinator.setCurrentURI(theEntry);
3154 } catch (IllegalStateException e) {
3155 logger.debug("Cannot paly favorite ({})", e.getMessage());
3158 logger.debug("Favorite '{}' not found", favorite);
3163 public void playTrack(Command command) {
3164 if (command instanceof DecimalType) {
3166 ZonePlayerHandler coordinator = getCoordinatorHandler();
3168 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3170 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3172 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3173 coordinator.setPositionTrack(trackNumber);
3175 // take the system off mute
3176 coordinator.setMute(OnOffType.OFF);
3180 } catch (IllegalStateException e) {
3181 logger.debug("Cannot play track ({})", e.getMessage());
3186 public void playPlayList(Command command) {
3187 if (command instanceof StringType) {
3188 String playlist = command.toString();
3189 List<SonosEntry> playlists = getPlayLists();
3191 SonosEntry theEntry = null;
3192 // search for the appropriate play list based on its name (title)
3193 for (SonosEntry somePlaylist : playlists) {
3194 if (somePlaylist.getTitle().equals(playlist)) {
3195 theEntry = somePlaylist;
3200 // set the URI of the group coordinator
3201 if (theEntry != null) {
3203 ZonePlayerHandler coordinator = getCoordinatorHandler();
3205 coordinator.addURIToQueue(theEntry);
3207 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3209 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3210 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3213 } catch (IllegalStateException e) {
3214 logger.debug("Cannot play playlist ({})", e.getMessage());
3217 logger.debug("Playlist '{}' not found", playlist);
3222 public void addURIToQueue(SonosEntry newEntry) {
3223 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3226 public @Nullable String getZoneName() {
3227 return stateMap.get("ZoneName");
3230 public @Nullable String getZoneGroupID() {
3231 return stateMap.get("LocalGroupUUID");
3234 public @Nullable String getRunningAlarmProperties() {
3235 return stateMap.get("RunningAlarmProperties");
3238 public @Nullable String getRefreshedRunningAlarmProperties() {
3239 updateRunningAlarmProperties();
3240 return getRunningAlarmProperties();
3243 public @Nullable String getMute() {
3244 return stateMap.get("MuteMaster");
3247 public @Nullable String getLed() {
3248 return stateMap.get("CurrentLEDState");
3251 public @Nullable String getCurrentZoneName() {
3252 return stateMap.get("CurrentZoneName");
3255 public @Nullable String getRefreshedCurrentZoneName() {
3256 updateCurrentZoneName();
3257 return getCurrentZoneName();
3261 public void onStatusChanged(boolean status) {
3263 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3264 if (getThing().getStatus() != ThingStatus.ONLINE) {
3265 updateStatus(ThingStatus.ONLINE);
3266 scheduler.execute(this::poll);
3269 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3270 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3274 private @Nullable String getModelNameFromDescriptor() {
3275 URL descriptor = service.getDescriptorURL(this);
3276 if (descriptor != null) {
3277 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3278 return sonosModelDescription == null ? null
3279 : SonosXMLParser.buildThingTypeIdFromModelName(sonosModelDescription);
3285 private boolean migrateThingType() {
3286 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3287 String modelName = getModelNameFromDescriptor();
3288 if (modelName != null && isSupportedModel(modelName)) {
3289 updateSonosThingType(modelName);
3296 private boolean isSupportedModel(String modelName) {
3297 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3298 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3305 private void updateSonosThingType(String newThingTypeID) {
3306 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3310 * Set the sleeptimer duration
3311 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3312 * Use empty String "" to switch the sleep timer off
3314 public void setSleepTimer(Command command) {
3315 if (command instanceof DecimalType) {
3316 this.service.invokeAction(this, SERVICE_AV_TRANSPORT, ACTION_CONFIGURE_SLEEP_TIMER, Map.of("InstanceID",
3317 "0", "NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue())));
3321 protected void updateSleepTimerDuration() {
3322 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_REMAINING_SLEEP_TIMER_DURATION, null);
3325 private String sleepSecondsToTimeStr(long sleepSeconds) {
3326 if (sleepSeconds == 0) {
3328 } else if (sleepSeconds < 68400) {
3329 long remainingSeconds = sleepSeconds;
3330 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3331 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3332 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3333 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3334 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3335 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3337 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3342 private long sleepStrTimeToSeconds(String sleepTime) {
3343 String[] units = sleepTime.split(":");
3344 int hours = Integer.parseInt(units[0]);
3345 int minutes = Integer.parseInt(units[1]);
3346 int seconds = Integer.parseInt(units[2]);
3347 return 3600 * hours + 60 * minutes + seconds;
3350 private @Nullable String extractInfoFromMoreInfo(String searchedInfo) {
3351 String value = stateMap.get("MoreInfo");
3352 if (value != null) {
3353 String[] fields = value.split(",");
3354 for (int i = 0; i < fields.length; i++) {
3355 String[] pair = fields[i].trim().split(":");
3356 if (pair.length == 2 && searchedInfo.equalsIgnoreCase(pair[0].trim())) {
3357 return pair[1].trim();