2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.sonos.internal.handler;
15 import static org.openhab.binding.sonos.internal.SonosBindingConstants.*;
17 import java.io.IOException;
18 import java.net.MalformedURLException;
20 import java.text.ParseException;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.Arrays;
24 import java.util.Calendar;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.Date;
28 import java.util.HashMap;
29 import java.util.List;
31 import java.util.TimeZone;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.openhab.binding.sonos.internal.SonosAlarm;
38 import org.openhab.binding.sonos.internal.SonosBindingConstants;
39 import org.openhab.binding.sonos.internal.SonosEntry;
40 import org.openhab.binding.sonos.internal.SonosMetaData;
41 import org.openhab.binding.sonos.internal.SonosMusicService;
42 import org.openhab.binding.sonos.internal.SonosResourceMetaData;
43 import org.openhab.binding.sonos.internal.SonosStateDescriptionOptionProvider;
44 import org.openhab.binding.sonos.internal.SonosXMLParser;
45 import org.openhab.binding.sonos.internal.SonosZoneGroup;
46 import org.openhab.binding.sonos.internal.SonosZonePlayerState;
47 import org.openhab.binding.sonos.internal.config.ZonePlayerConfiguration;
48 import org.openhab.core.io.net.http.HttpUtil;
49 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
50 import org.openhab.core.io.transport.upnp.UpnpIOService;
51 import org.openhab.core.library.types.DecimalType;
52 import org.openhab.core.library.types.IncreaseDecreaseType;
53 import org.openhab.core.library.types.NextPreviousType;
54 import org.openhab.core.library.types.OnOffType;
55 import org.openhab.core.library.types.OpenClosedType;
56 import org.openhab.core.library.types.PercentType;
57 import org.openhab.core.library.types.PlayPauseType;
58 import org.openhab.core.library.types.RawType;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.library.types.UpDownType;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingRegistry;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.ThingTypeUID;
67 import org.openhab.core.thing.ThingUID;
68 import org.openhab.core.thing.binding.BaseThingHandler;
69 import org.openhab.core.thing.binding.ThingHandler;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.State;
73 import org.openhab.core.types.StateOption;
74 import org.openhab.core.types.UnDefType;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
79 * The {@link ZonePlayerHandler} is responsible for handling commands, which are
80 * sent to one of the channels.
82 * @author Karel Goderis - Initial contribution
85 public class ZonePlayerHandler extends BaseThingHandler implements UpnpIOParticipant {
87 private static final String ANALOG_LINE_IN_URI = "x-rincon-stream:";
88 private static final String OPTICAL_LINE_IN_URI = "x-sonos-htastream:";
89 private static final String QUEUE_URI = "x-rincon-queue:";
90 private static final String GROUP_URI = "x-rincon:";
91 private static final String STREAM_URI = "x-sonosapi-stream:";
92 private static final String RADIO_URI = "x-sonosapi-radio:";
93 private static final String RADIO_MP3_URI = "x-rincon-mp3radio:";
94 private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
95 private static final String FILE_URI = "x-file-cifs:";
96 private static final String SPDIF = ":spdif";
97 private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
99 private static final String STATE_PLAYING = "PLAYING";
100 private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
101 private static final String STATE_STOPPED = "STOPPED";
103 private static final String LINEINCONNECTED = "LineInConnected";
104 private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
106 private static final String SERVICE_DEVICE_PROPERTIES = "DeviceProperties";
107 private static final String SERVICE_AV_TRANSPORT = "AVTransport";
108 private static final String SERVICE_RENDERING_CONTROL = "RenderingControl";
109 private static final String SERVICE_ZONE_GROUP_TOPOLOGY = "ZoneGroupTopology";
110 private static final String SERVICE_GROUP_MANAGEMENT = "GroupManagement";
111 private static final String SERVICE_AUDIO_IN = "AudioIn";
112 private static final String SERVICE_HT_CONTROL = "HTControl";
113 private static final String SERVICE_CONTENT_DIRECTORY = "ContentDirectory";
114 private static final String SERVICE_ALARM_CLOCK = "AlarmClock";
116 private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList(SERVICE_DEVICE_PROPERTIES,
117 SERVICE_AV_TRANSPORT, SERVICE_ZONE_GROUP_TOPOLOGY, SERVICE_GROUP_MANAGEMENT, SERVICE_RENDERING_CONTROL,
118 SERVICE_AUDIO_IN, SERVICE_HT_CONTROL, SERVICE_CONTENT_DIRECTORY);
119 protected static final int SUBSCRIPTION_DURATION = 1800;
121 private static final String ACTION_GET_ZONE_ATTRIBUTES = "GetZoneAttributes";
122 private static final String ACTION_GET_ZONE_INFO = "GetZoneInfo";
123 private static final String ACTION_GET_LED_STATE = "GetLEDState";
124 private static final String ACTION_SET_LED_STATE = "SetLEDState";
126 private static final String ACTION_GET_POSITION_INFO = "GetPositionInfo";
127 private static final String ACTION_SET_AV_TRANSPORT_URI = "SetAVTransportURI";
128 private static final String ACTION_SEEK = "Seek";
129 private static final String ACTION_PLAY = "Play";
130 private static final String ACTION_STOP = "Stop";
131 private static final String ACTION_PAUSE = "Pause";
132 private static final String ACTION_PREVIOUS = "Previous";
133 private static final String ACTION_NEXT = "Next";
134 private static final String ACTION_ADD_URI_TO_QUEUE = "AddURIToQueue";
135 private static final String ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE = "RemoveTrackRangeFromQueue";
136 private static final String ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE = "RemoveAllTracksFromQueue";
137 private static final String ACTION_SAVE_QUEUE = "SaveQueue";
138 private static final String ACTION_SET_PLAY_MODE = "SetPlayMode";
139 private static final String ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP = "BecomeCoordinatorOfStandaloneGroup";
140 private static final String ACTION_GET_RUNNING_ALARM_PROPERTIES = "GetRunningAlarmProperties";
141 private static final String ACTION_SNOOZE_ALARM = "SnoozeAlarm";
142 private static final String ACTION_GET_REMAINING_SLEEP_TIMER_DURATION = "GetRemainingSleepTimerDuration";
143 private static final String ACTION_CONFIGURE_SLEEP_TIMER = "ConfigureSleepTimer";
145 private static final String ACTION_SET_VOLUME = "SetVolume";
146 private static final String ACTION_SET_MUTE = "SetMute";
147 private static final String ACTION_SET_BASS = "SetBass";
148 private static final String ACTION_SET_TREBLE = "SetTreble";
149 private static final String ACTION_SET_LOUDNESS = "SetLoudness";
150 private static final String ACTION_SET_EQ = "SetEQ";
152 private static final int SOCKET_TIMEOUT = 5000;
154 private static final int TUNEIN_DEFAULT_SERVICE_TYPE = 65031;
156 private static final int MIN_BASS = -10;
157 private static final int MAX_BASS = 10;
158 private static final int MIN_TREBLE = -10;
159 private static final int MAX_TREBLE = 10;
160 private static final int MIN_SUBWOOFER_GAIN = -15;
161 private static final int MAX_SUBWOOFER_GAIN = 15;
162 private static final int MIN_SURROUND_LEVEL = -15;
163 private static final int MAX_SURROUND_LEVEL = 15;
165 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
167 private final ThingRegistry localThingRegistry;
168 private final UpnpIOService service;
169 private final @Nullable String opmlUrl;
170 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
172 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
175 * Intrinsic lock used to synchronize the execution of notification sounds
177 private final Object notificationLock = new Object();
178 private final Object upnpLock = new Object();
179 private final Object stateLock = new Object();
180 private final Object jobLock = new Object();
182 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
184 private @Nullable ScheduledFuture<?> pollingJob;
185 private @Nullable SonosZonePlayerState savedState;
187 private Map<String, Boolean> subscriptionState = new HashMap<>();
190 * Thing handler instance of the coordinator speaker used for control delegation
192 private @Nullable ZonePlayerHandler coordinatorHandler;
194 private @Nullable List<SonosMusicService> musicServices;
196 private enum LineInType {
202 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
203 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
205 this.localThingRegistry = thingRegistry;
206 this.opmlUrl = opmlUrl;
207 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
208 this.service = upnpIOService;
209 this.stateDescriptionProvider = stateDescriptionProvider;
213 public void dispose() {
214 logger.debug("Handler disposed for thing {}", getThing().getUID());
216 ScheduledFuture<?> job = this.pollingJob;
220 this.pollingJob = null;
222 removeSubscription();
223 service.unregisterParticipant(this);
227 public void initialize() {
228 logger.debug("initializing handler for thing {}", getThing().getUID());
230 if (migrateThingType()) {
231 // we change the type, so we might need a different handler -> let's finish
235 configuration = getConfigAs(ZonePlayerConfiguration.class);
236 String udn = configuration.udn;
237 if (udn != null && !udn.isEmpty()) {
238 service.registerParticipant(this);
239 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
241 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
242 "@text/offline.conf-error-missing-udn");
243 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
247 private void poll() {
248 synchronized (jobLock) {
249 if (pollingJob == null) {
253 logger.debug("Polling job");
255 // First check if the Sonos zone is set in the UPnP service registry
256 // If not, set the thing state to OFFLINE and wait for the next poll
257 if (!isUpnpDeviceRegistered()) {
258 logger.debug("UPnP device {} not yet registered", getUDN());
259 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
260 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
261 synchronized (upnpLock) {
262 subscriptionState = new HashMap<>();
267 // Check if the Sonos zone can be joined
268 // If not, set the thing state to OFFLINE and do nothing else
270 if (getThing().getStatus() != ThingStatus.ONLINE) {
276 if (isLinked(ZONENAME)) {
277 updateCurrentZoneName();
282 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
283 if (isLinked(SLEEPTIMER) && isCoordinator()) {
284 updateSleepTimerDuration();
286 } catch (Exception e) {
287 logger.debug("Exception during poll: {}", e.getMessage(), e);
293 public void handleCommand(ChannelUID channelUID, Command command) {
294 if (command == RefreshType.REFRESH) {
295 updateChannel(channelUID.getId());
297 switch (channelUID.getId()) {
304 case NOTIFICATIONSOUND:
305 scheduleNotificationSound(command);
308 stopPlaying(command);
311 setVolumeForGroup(command);
320 setLoudness(command);
323 setSubwoofer(command);
326 setSubwooferGain(command);
329 setSurround(command);
331 case SURROUNDMUSICMODE:
332 setSurroundMusicMode(command);
334 case SURROUNDMUSICLEVEL:
335 setSurroundMusicLevel(command);
337 case SURROUNDTVLEVEL:
338 setSurroundTvLevel(command);
344 removeMember(command);
347 becomeStandAlonePlayer();
350 publicAddress(LineInType.ANY);
352 case PUBLICANALOGADDRESS:
353 publicAddress(LineInType.ANALOG);
355 case PUBLICDIGITALADDRESS:
356 publicAddress(LineInType.DIGITAL);
361 case TUNEINSTATIONID:
362 playTuneinStation(command);
365 playFavorite(command);
371 snoozeAlarm(command);
374 saveAllPlayerState();
377 restoreAllPlayerState();
386 playPlayList(command);
405 if (command instanceof PlayPauseType) {
406 if (command == PlayPauseType.PLAY) {
407 getCoordinatorHandler().play();
408 } else if (command == PlayPauseType.PAUSE) {
409 getCoordinatorHandler().pause();
412 if (command instanceof NextPreviousType) {
413 if (command == NextPreviousType.NEXT) {
414 getCoordinatorHandler().next();
415 } else if (command == NextPreviousType.PREVIOUS) {
416 getCoordinatorHandler().previous();
419 // Rewind and Fast Forward are currently not implemented by the binding
420 } catch (IllegalStateException e) {
421 logger.debug("Cannot handle control command ({})", e.getMessage());
425 setSleepTimer(command);
434 setNightMode(command);
436 case SPEECHENHANCEMENT:
437 setSpeechEnhancement(command);
445 private void restoreAllPlayerState() {
446 for (Thing aThing : localThingRegistry.getAll()) {
447 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
448 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
449 if (handler != null) {
450 handler.restoreState();
456 private void saveAllPlayerState() {
457 for (Thing aThing : localThingRegistry.getAll()) {
458 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
459 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
460 if (handler != null) {
468 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
469 if (variable == null || value == null || service == null) {
473 if (getThing().getStatus() == ThingStatus.ONLINE) {
474 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
475 new Object[] { variable, value, service, this.getThing().getUID() });
477 String oldValue = this.stateMap.get(variable);
478 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
482 this.stateMap.put(variable, value);
484 // pre-process some variables, eg XML processing
485 if (service.equals(SERVICE_AV_TRANSPORT) && variable.equals("LastChange")) {
486 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
487 parsedValues.forEach((variable1, value1) -> {
488 // Update the transport state after the update of the media information
489 // to not break the notification mechanism
490 if (!variable1.equals("TransportState")) {
491 onValueReceived(variable1, value1, service);
493 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
494 // for a compatibility with the result of the action GetMediaInfo
495 if (variable1.equals("AVTransportURI")) {
496 onValueReceived("CurrentURI", value1, service);
497 } else if (variable1.equals("AVTransportURIMetaData")) {
498 onValueReceived("CurrentURIMetaData", value1, service);
501 updateMediaInformation();
502 if (parsedValues.get("TransportState") != null) {
503 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
507 if (service.equals(SERVICE_RENDERING_CONTROL) && variable.equals("LastChange")) {
508 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
509 parsedValues.forEach((variable1, value1) -> {
510 onValueReceived(variable1, value1, service);
514 List<StateOption> options = new ArrayList<>();
516 // update the appropriate channel
518 case "TransportState":
519 updateChannel(STATE);
520 updateChannel(CONTROL);
522 dispatchOnAllGroupMembers(variable, value, service);
524 case "CurrentPlayMode":
525 updateChannel(SHUFFLE);
526 updateChannel(REPEAT);
527 dispatchOnAllGroupMembers(variable, value, service);
529 case "CurrentLEDState":
533 updateState(ZONENAME, new StringType(value));
535 case "CurrentZoneName":
536 updateChannel(ZONENAME);
538 case "ZoneGroupState":
539 updateChannel(COORDINATOR);
540 // Update coordinator after a change is made to the grouping of Sonos players
541 updateGroupCoordinator();
542 updateMediaInformation();
543 // Update state and control channels for the group members with the coordinator values
544 String transportState = getTransportState();
545 if (transportState != null) {
546 dispatchOnAllGroupMembers("TransportState", transportState, SERVICE_AV_TRANSPORT);
548 // Update shuffle and repeat channels for the group members with the coordinator values
549 String playMode = getPlayMode();
550 if (playMode != null) {
551 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, SERVICE_AV_TRANSPORT);
554 case "LocalGroupUUID":
555 updateChannel(ZONEGROUPID);
557 case "GroupCoordinatorIsLocal":
558 updateChannel(LOCALCOORDINATOR);
561 updateChannel(VOLUME);
570 updateChannel(TREBLE);
572 case "LoudnessMaster":
573 updateChannel(LOUDNESS);
577 updateChannel(TREBLE);
578 updateChannel(LOUDNESS);
581 updateChannel(SUBWOOFER);
584 updateChannel(SUBWOOFERGAIN);
586 case "SurroundEnabled":
587 updateChannel(SURROUND);
590 updateChannel(SURROUNDMUSICMODE);
592 case "SurroundLevel":
593 updateChannel(SURROUNDTVLEVEL);
595 case "MusicSurroundLevel":
596 updateChannel(SURROUNDMUSICLEVEL);
599 updateChannel(NIGHTMODE);
602 updateChannel(SPEECHENHANCEMENT);
604 case LINEINCONNECTED:
605 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
606 updateChannel(LINEIN);
608 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
609 .contains(getThing().getThingTypeUID())) {
610 updateChannel(ANALOGLINEIN);
613 case TOSLINEINCONNECTED:
614 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
615 updateChannel(LINEIN);
617 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
618 .contains(getThing().getThingTypeUID())) {
619 updateChannel(DIGITALLINEIN);
623 updateChannel(ALARMRUNNING);
624 updateRunningAlarmProperties();
626 case "RunningAlarmProperties":
627 updateChannel(ALARMPROPERTIES);
629 case "CurrentURIFormatted":
630 updateChannel(CURRENTTRACK);
633 updateChannel(CURRENTTITLE);
635 case "CurrentArtist":
636 updateChannel(CURRENTARTIST);
639 updateChannel(CURRENTALBUM);
642 updateChannel(CURRENTTRANSPORTURI);
644 case "CurrentTrackURI":
645 updateChannel(CURRENTTRACKURI);
647 case "CurrentAlbumArtURI":
648 updateChannel(CURRENTALBUMARTURL);
650 case "CurrentSleepTimerGeneration":
651 if (value.equals("0")) {
652 updateState(SLEEPTIMER, new DecimalType(0));
655 case "SleepTimerGeneration":
656 if (value.equals("0")) {
657 updateState(SLEEPTIMER, new DecimalType(0));
659 updateSleepTimerDuration();
662 case "RemainingSleepTimerDuration":
663 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
665 case "CurrentTuneInStationId":
666 updateChannel(TUNEINSTATIONID);
668 case "SavedQueuesUpdateID": // service ContentDirectoy
669 for (SonosEntry entry : getPlayLists()) {
670 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
672 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
674 case "FavoritesUpdateID": // service ContentDirectoy
675 for (SonosEntry entry : getFavorites()) {
676 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
678 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
680 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
681 // Due to a bug in the data type definition of this state variable, it is not set.
682 // As a workaround, we check the state variable named ContainerUpdateIDs.
683 case "ContainerUpdateIDs": // service ContentDirectoy
684 if (value.startsWith("R:0,") || stateDescriptionProvider
685 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
686 for (SonosEntry entry : getFavoriteRadios()) {
687 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
689 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
693 updateChannel(BATTERYCHARGING);
694 updateChannel(BATTERYLEVEL);
697 updateChannel(MICROPHONE);
705 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
706 if (isCoordinator()) {
707 for (String member : getOtherZoneGroupMembers()) {
709 ZonePlayerHandler memberHandler = getHandlerByName(member);
710 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
711 memberHandler.onValueReceived(variable, value, service);
713 } catch (IllegalStateException e) {
714 logger.debug("Cannot update channel for group member ({})", e.getMessage());
720 private @Nullable String getAlbumArtUrl() {
722 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
723 if (albumArtURI != null) {
724 if (albumArtURI.startsWith("http")) {
726 } else if (albumArtURI.startsWith("/")) {
728 URL serviceDescrUrl = service.getDescriptorURL(this);
729 if (serviceDescrUrl != null) {
730 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
731 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
733 } catch (MalformedURLException e) {
734 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
741 protected void updateChannel(String channelId) {
742 if (!isLinked(channelId)) {
748 State newState = UnDefType.UNDEF;
752 value = getTransportState();
754 newState = new StringType(value);
758 value = getTransportState();
759 if (STATE_PLAYING.equals(value)) {
760 newState = PlayPauseType.PLAY;
761 } else if (STATE_STOPPED.equals(value)) {
762 newState = PlayPauseType.PAUSE;
763 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
764 newState = PlayPauseType.PAUSE;
768 value = getTransportState();
770 newState = OnOffType.from(STATE_STOPPED.equals(value));
774 if (getPlayMode() != null) {
775 newState = OnOffType.from(isShuffleActive());
779 if (getPlayMode() != null) {
780 newState = new StringType(getRepeatMode());
786 newState = OnOffType.from(value);
790 value = getCurrentZoneName();
792 newState = new StringType(value);
796 value = getZoneGroupID();
798 newState = new StringType(value);
802 newState = new StringType(getCoordinator());
804 case LOCALCOORDINATOR:
805 if (getGroupCoordinatorIsLocal() != null) {
806 newState = OnOffType.from(isGroupCoordinator());
812 newState = new PercentType(value);
817 if (value != null && !isOutputLevelFixed()) {
818 newState = new DecimalType(value);
823 if (value != null && !isOutputLevelFixed()) {
824 newState = new DecimalType(value);
828 value = getLoudness();
829 if (value != null && !isOutputLevelFixed()) {
830 newState = OnOffType.from(value);
836 newState = OnOffType.from(value);
840 value = getSubwooferEnabled();
842 newState = OnOffType.from(value);
846 value = getSubwooferGain();
848 newState = new DecimalType(value);
852 value = getSurroundEnabled();
854 newState = OnOffType.from(value);
857 case SURROUNDMUSICMODE:
858 value = getSurroundMusicMode();
860 newState = new StringType(value);
863 case SURROUNDMUSICLEVEL:
864 value = getSurroundMusicLevel();
866 newState = new DecimalType(value);
869 case SURROUNDTVLEVEL:
870 value = getSurroundTvLevel();
872 newState = new DecimalType(value);
876 value = getNightMode();
878 newState = OnOffType.from(value);
881 case SPEECHENHANCEMENT:
882 value = getDialogLevel();
884 newState = OnOffType.from(value);
888 if (getAnalogLineInConnected() != null) {
889 newState = OnOffType.from(isAnalogLineInConnected());
890 } else if (getOpticalLineInConnected() != null) {
891 newState = OnOffType.from(isOpticalLineInConnected());
895 if (getAnalogLineInConnected() != null) {
896 newState = OnOffType.from(isAnalogLineInConnected());
900 if (getOpticalLineInConnected() != null) {
901 newState = OnOffType.from(isOpticalLineInConnected());
905 if (getAlarmRunning() != null) {
906 newState = OnOffType.from(isAlarmRunning());
909 case ALARMPROPERTIES:
910 value = getRunningAlarmProperties();
912 newState = new StringType(value);
916 value = stateMap.get("CurrentURIFormatted");
918 newState = new StringType(value);
922 value = getCurrentTitle();
924 newState = new StringType(value);
928 value = getCurrentArtist();
930 newState = new StringType(value);
934 value = getCurrentAlbum();
936 newState = new StringType(value);
939 case CURRENTALBUMART:
941 updateAlbumArtChannel(false);
943 case CURRENTALBUMARTURL:
944 url = getAlbumArtUrl();
946 newState = new StringType(url);
949 case CURRENTTRANSPORTURI:
950 value = getCurrentURI();
952 newState = new StringType(value);
955 case CURRENTTRACKURI:
956 value = stateMap.get("CurrentTrackURI");
958 newState = new StringType(value);
961 case TUNEINSTATIONID:
962 value = stateMap.get("CurrentTuneInStationId");
964 newState = new StringType(value);
967 case BATTERYCHARGING:
968 value = extractInfoFromMoreInfo("BattChg");
970 newState = OnOffType.from("CHARGING".equalsIgnoreCase(value));
974 value = extractInfoFromMoreInfo("RawBattPct");
976 newState = new DecimalType(value);
980 value = getMicEnabled();
982 newState = OnOffType.from(value);
989 if (newState != null) {
990 updateState(channelId, newState);
994 private void updateAlbumArtChannel(boolean allGroup) {
995 String url = getAlbumArtUrl();
997 // We download the cover art in a different thread to not delay the other operations
998 scheduler.submit(() -> {
999 RawType image = HttpUtil.downloadImage(url, true, 500000);
1000 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
1003 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
1007 private void updateChannel(String channeldD, State state, boolean allGroup) {
1009 for (String member : getZoneGroupMembers()) {
1011 ZonePlayerHandler memberHandler = getHandlerByName(member);
1012 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
1013 && memberHandler.isLinked(channeldD)) {
1014 memberHandler.updateState(channeldD, state);
1016 } catch (IllegalStateException e) {
1017 logger.debug("Cannot update channel for group member ({})", e.getMessage());
1020 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
1021 updateState(channeldD, state);
1026 * CurrentURI will not change, but will trigger change of CurrentURIFormated
1027 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
1029 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
1030 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
1033 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
1034 return oldValue != null ? !oldValue.equals(value) : value != null;
1038 * Similar to the AVTransport eventing, the Queue events its state variables
1039 * as sub values within a synthesized LastChange state variable.
1041 private boolean isQueueEvent(String variable) {
1042 return "LastChange".equals(variable);
1045 private void updateGroupCoordinator() {
1047 coordinatorHandler = getHandlerByName(getCoordinator());
1048 } catch (IllegalStateException e) {
1049 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
1050 coordinatorHandler = null;
1054 private boolean isUpnpDeviceRegistered() {
1055 return service.isRegistered(this);
1058 private void addSubscription() {
1059 synchronized (upnpLock) {
1060 // Set up GENA Subscriptions
1061 if (service.isRegistered(this)) {
1062 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1063 Boolean state = subscriptionState.get(subscription);
1064 if (state == null || !state) {
1065 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
1066 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
1067 subscriptionState.put(subscription, true);
1074 private void removeSubscription() {
1075 synchronized (upnpLock) {
1076 // Set up GENA Subscriptions
1077 if (service.isRegistered(this)) {
1078 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1079 Boolean state = subscriptionState.get(subscription);
1080 if (state != null && state) {
1081 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
1082 service.removeSubscription(this, subscription);
1086 subscriptionState = new HashMap<>();
1091 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
1092 if (service == null) {
1095 synchronized (upnpLock) {
1096 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
1097 subscriptionState.put(service, succeeded);
1101 private Map<String, String> executeAction(String serviceId, String actionId, @Nullable Map<String, String> inputs) {
1102 Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
1103 result.forEach((variable, value) -> {
1104 this.onValueReceived(variable, value, serviceId);
1109 private void updatePlayerState() {
1110 if (!updateZoneInfo()) {
1111 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
1112 logger.debug("Sonos player {} is not available in local network", getUDN());
1113 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1114 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
1115 synchronized (upnpLock) {
1116 subscriptionState = new HashMap<>();
1119 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
1120 logger.debug("Sonos player {} has been found in local network", getUDN());
1121 updateStatus(ThingStatus.ONLINE);
1125 protected void updateCurrentZoneName() {
1126 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_ATTRIBUTES, null);
1129 protected void updateLed() {
1130 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
1133 protected void updateTime() {
1134 executeAction(SERVICE_ALARM_CLOCK, "GetTimeNow", null);
1137 protected void updatePosition() {
1138 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_POSITION_INFO, null);
1141 protected void updateRunningAlarmProperties() {
1142 Map<String, String> result = service.invokeAction(this, SERVICE_AV_TRANSPORT,
1143 ACTION_GET_RUNNING_ALARM_PROPERTIES, null);
1145 String alarmID = result.get("AlarmID");
1146 String loggedStartTime = result.get("LoggedStartTime");
1147 String newStringValue = null;
1148 if (alarmID != null && loggedStartTime != null) {
1149 newStringValue = alarmID + " - " + loggedStartTime;
1151 newStringValue = "No running alarm";
1153 result.put("RunningAlarmProperties", newStringValue);
1155 result.forEach((variable, value) -> {
1156 this.onValueReceived(variable, value, SERVICE_AV_TRANSPORT);
1160 protected boolean updateZoneInfo() {
1161 Map<String, String> result = executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_INFO, null);
1163 Map<String, String> properties = editProperties();
1164 String value = stateMap.get("HardwareVersion");
1165 if (value != null && !value.isEmpty()) {
1166 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1168 value = stateMap.get("DisplaySoftwareVersion");
1169 if (value != null && !value.isEmpty()) {
1170 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1172 value = stateMap.get("SerialNumber");
1173 if (value != null && !value.isEmpty()) {
1174 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1176 value = stateMap.get("MACAddress");
1177 if (value != null && !value.isEmpty()) {
1178 properties.put(MAC_ADDRESS, value);
1180 value = stateMap.get("IPAddress");
1181 if (value != null && !value.isEmpty()) {
1182 properties.put(IP_ADDRESS, value);
1184 updateProperties(properties);
1186 return !result.isEmpty();
1189 public String getCoordinator() {
1190 for (SonosZoneGroup zg : getZoneGroups()) {
1191 if (zg.getMembers().contains(getUDN())) {
1192 return zg.getCoordinator();
1198 public boolean isCoordinator() {
1199 return getUDN().equals(getCoordinator());
1202 protected void updateMediaInformation() {
1203 String currentURI = getCurrentURI();
1204 SonosMetaData currentTrack = getTrackMetadata();
1205 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1207 String artist = null;
1208 String album = null;
1209 String title = null;
1210 String resultString = null;
1211 String stationID = null;
1212 boolean needsUpdating = false;
1214 // if currentURI == null, we do nothing
1215 if (currentURI != null) {
1216 if (currentURI.isEmpty()) {
1218 needsUpdating = true;
1221 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1222 // The Sonos is a slave member of a group
1223 // The media information will be updated by the coordinator
1224 // Notification of group change occurs later, so we just check the URI
1226 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1227 // Radio stream (tune-in)
1228 boolean opmlUrlSucceeded = false;
1229 stationID = extractStationId(currentURI);
1230 String url = opmlUrl;
1232 String mac = getMACAddress();
1233 if (stationID != null && !stationID.isEmpty() && mac != null && !mac.isEmpty()) {
1234 url = url.replace("%id", stationID);
1235 url = url.replace("%serial", mac);
1237 String response = null;
1239 response = HttpUtil.executeUrl("GET", url, SOCKET_TIMEOUT);
1240 } catch (IOException e) {
1241 logger.debug("Request to device failed", e);
1244 if (response != null) {
1245 List<String> fields = SonosXMLParser.getRadioTimeFromXML(response);
1247 if (!fields.isEmpty()) {
1248 opmlUrlSucceeded = true;
1251 for (String field : fields) {
1252 if (resultString.isEmpty()) {
1253 // radio name should be first field
1256 resultString += " - ";
1258 resultString += field;
1261 needsUpdating = true;
1266 if (!opmlUrlSucceeded) {
1267 if (currentUriMetaData != null) {
1268 title = currentUriMetaData.getTitle();
1269 if (currentTrack == null || currentTrack.getStreamContent().isEmpty()) {
1270 resultString = title;
1272 resultString = title + " - " + currentTrack.getStreamContent();
1274 needsUpdating = true;
1279 else if (isPlayingLineIn(currentURI)) {
1280 if (currentTrack != null) {
1281 title = currentTrack.getTitle();
1282 resultString = title;
1283 needsUpdating = true;
1287 else if (isPlayingRadio(currentURI)
1288 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1289 // isPlayingRadio(currentURI) is true for Google Play Music radio or Apple Music radio
1290 if (currentTrack != null) {
1291 artist = !currentTrack.getAlbumArtist().isEmpty() ? currentTrack.getAlbumArtist()
1292 : currentTrack.getCreator();
1293 album = currentTrack.getAlbum();
1294 title = currentTrack.getTitle();
1295 resultString = artist + " - " + album + " - " + title;
1296 needsUpdating = true;
1301 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1302 ? currentTrack.getAlbumArtUri()
1305 ZonePlayerHandler handlerForImageUpdate = null;
1306 for (String member : getZoneGroupMembers()) {
1308 ZonePlayerHandler memberHandler = getHandlerByName(member);
1309 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1310 if (memberHandler.isLinked(CURRENTALBUMART)
1311 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1312 handlerForImageUpdate = memberHandler;
1314 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1315 SERVICE_AV_TRANSPORT);
1316 if (needsUpdating) {
1317 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "",
1318 SERVICE_AV_TRANSPORT);
1319 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "",
1320 SERVICE_AV_TRANSPORT);
1321 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "",
1322 SERVICE_AV_TRANSPORT);
1323 memberHandler.onValueReceived("CurrentURIFormatted", (resultString != null) ? resultString : "",
1324 SERVICE_AV_TRANSPORT);
1325 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, SERVICE_AV_TRANSPORT);
1328 } catch (IllegalStateException e) {
1329 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1332 if (needsUpdating && handlerForImageUpdate != null) {
1333 handlerForImageUpdate.updateAlbumArtChannel(true);
1337 private @Nullable String extractStationId(String uri) {
1338 String stationID = null;
1339 if (isPlayingStream(uri)) {
1340 stationID = substringBetween(uri, ":s", "?sid");
1341 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1342 stationID = substringBetween(uri, "sid=s", "&");
1347 private @Nullable String substringBetween(String str, String open, String close) {
1348 String result = null;
1349 int idx1 = str.indexOf(open);
1351 idx1 += open.length();
1352 int idx2 = str.indexOf(close, idx1);
1354 result = str.substring(idx1, idx2);
1360 public @Nullable String getGroupCoordinatorIsLocal() {
1361 return stateMap.get("GroupCoordinatorIsLocal");
1364 public boolean isGroupCoordinator() {
1365 return "true".equals(getGroupCoordinatorIsLocal());
1369 public String getUDN() {
1370 String udn = configuration.udn;
1371 return udn != null && !udn.isEmpty() ? udn : "undefined";
1374 public @Nullable String getCurrentURI() {
1375 return stateMap.get("CurrentURI");
1378 public @Nullable String getCurrentURIMetadataAsString() {
1379 return stateMap.get("CurrentURIMetaData");
1382 public @Nullable SonosMetaData getCurrentURIMetadata() {
1383 String metaData = getCurrentURIMetadataAsString();
1384 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1387 public @Nullable SonosMetaData getTrackMetadata() {
1388 String metaData = stateMap.get("CurrentTrackMetaData");
1389 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1392 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1393 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1394 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1397 public @Nullable String getMACAddress() {
1398 String mac = stateMap.get("MACAddress");
1399 if (mac == null || mac.isEmpty()) {
1402 return stateMap.get("MACAddress");
1405 public @Nullable String getRefreshedPosition() {
1407 return stateMap.get("RelTime");
1410 public long getRefreshedCurrenTrackNr() {
1412 String value = stateMap.get("Track");
1413 if (value != null) {
1414 return Long.valueOf(value);
1420 public @Nullable String getVolume() {
1421 return stateMap.get("VolumeMaster");
1424 public boolean isOutputLevelFixed() {
1425 return "1".equals(stateMap.get("OutputFixed"));
1428 public @Nullable String getBass() {
1429 return stateMap.get("Bass");
1432 public @Nullable String getTreble() {
1433 return stateMap.get("Treble");
1436 public @Nullable String getLoudness() {
1437 return stateMap.get("LoudnessMaster");
1440 public @Nullable String getSurroundEnabled() {
1441 return stateMap.get("SurroundEnabled");
1444 public @Nullable String getSurroundMusicMode() {
1445 return stateMap.get("SurroundMode");
1448 public @Nullable String getSurroundTvLevel() {
1449 return stateMap.get("SurroundLevel");
1452 public @Nullable String getSurroundMusicLevel() {
1453 return stateMap.get("MusicSurroundLevel");
1456 public @Nullable String getSubwooferEnabled() {
1457 return stateMap.get("SubEnabled");
1460 public @Nullable String getSubwooferGain() {
1461 return stateMap.get("SubGain");
1464 public @Nullable String getTransportState() {
1465 return stateMap.get("TransportState");
1468 public @Nullable String getCurrentTitle() {
1469 return stateMap.get("CurrentTitle");
1472 public @Nullable String getCurrentArtist() {
1473 return stateMap.get("CurrentArtist");
1476 public @Nullable String getCurrentAlbum() {
1477 return stateMap.get("CurrentAlbum");
1480 public List<SonosEntry> getArtists(String filter) {
1481 return getEntries("A:", filter);
1484 public List<SonosEntry> getArtists() {
1485 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1488 public List<SonosEntry> getAlbums(String filter) {
1489 return getEntries("A:ALBUM", filter);
1492 public List<SonosEntry> getAlbums() {
1493 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1496 public List<SonosEntry> getTracks(String filter) {
1497 return getEntries("A:TRACKS", filter);
1500 public List<SonosEntry> getTracks() {
1501 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1504 public List<SonosEntry> getQueue(String filter) {
1505 return getEntries("Q:0", filter);
1508 public List<SonosEntry> getQueue() {
1509 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1512 public long getQueueSize() {
1513 return getNbEntries("Q:0");
1516 public List<SonosEntry> getPlayLists(String filter) {
1517 return getEntries("SQ:", filter);
1520 public List<SonosEntry> getPlayLists() {
1521 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1524 public List<SonosEntry> getFavoriteRadios(String filter) {
1525 return getEntries("R:0/0", filter);
1528 public List<SonosEntry> getFavoriteRadios() {
1529 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1533 * Searches for entries in the 'favorites' list on a sonos account
1537 public List<SonosEntry> getFavorites() {
1538 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1541 protected List<SonosEntry> getEntries(String type, String filter) {
1544 Map<String, String> inputs = new HashMap<>();
1545 inputs.put("ObjectID", type);
1546 inputs.put("BrowseFlag", "BrowseDirectChildren");
1547 inputs.put("Filter", filter);
1548 inputs.put("StartingIndex", Long.toString(startAt));
1549 inputs.put("RequestedCount", Integer.toString(200));
1550 inputs.put("SortCriteria", "");
1552 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1554 String initialResult = result.get("Result");
1555 if (initialResult == null) {
1556 return Collections.emptyList();
1559 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1560 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1562 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1563 startAt = startAt + initialNumberReturned;
1565 while (startAt < totalMatches) {
1566 inputs.put("StartingIndex", Long.toString(startAt));
1567 result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1569 // Execute this action synchronously
1570 String nextResult = result.get("Result");
1571 if (nextResult == null) {
1575 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1577 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1579 startAt = startAt + numberReturned;
1585 protected long getNbEntries(String type) {
1586 Map<String, String> inputs = new HashMap<>();
1587 inputs.put("ObjectID", type);
1588 inputs.put("BrowseFlag", "BrowseDirectChildren");
1589 inputs.put("Filter", "dc:title");
1590 inputs.put("StartingIndex", "0");
1591 inputs.put("RequestedCount", "1");
1592 inputs.put("SortCriteria", "");
1594 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1596 return getResultEntry(result, "TotalMatches", type, "dc:title");
1600 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1602 * @param resultInput - the map to be examined for the requestedKey
1603 * @param requestedKey - the key to be sought in the resultInput map
1604 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1605 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1607 * @return 0 as long or the value corresponding to the requiredKey if found
1609 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1610 String entriesFilter) {
1613 if (resultInput.isEmpty()) {
1618 String resultString = resultInput.get(requestedKey);
1619 if (resultString == null) {
1620 throw new NumberFormatException("Requested key is null.");
1622 result = Long.valueOf(resultString);
1623 } catch (NumberFormatException ex) {
1624 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1625 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1632 * Save the state (track, position etc) of the Sonos Zone player.
1634 * @return true if no error occurred.
1636 protected void saveState() {
1637 synchronized (stateLock) {
1638 savedState = new SonosZonePlayerState();
1639 String currentURI = getCurrentURI();
1641 savedState.transportState = getTransportState();
1642 savedState.volume = getVolume();
1644 if (currentURI != null) {
1645 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
1646 || isPlayingRadio(currentURI)) {
1647 // we are streaming music, like tune-in radio or Google Play Music radio
1648 SonosMetaData track = getTrackMetadata();
1649 SonosMetaData current = getCurrentURIMetadata();
1650 if (track != null && current != null) {
1651 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1652 current.getUpnpClass(), currentURI);
1654 } else if (currentURI.contains(GROUP_URI)) {
1655 // we are a slave to some coordinator
1656 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1657 } else if (isPlayingLineIn(currentURI)) {
1658 // we are streaming from the Line In connection
1659 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1660 } else if (isPlayingQueue(currentURI)) {
1661 // we are playing something that sits in the queue
1662 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1663 if (queued != null) {
1664 savedState.track = getRefreshedCurrenTrackNr();
1666 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1667 // we are playing a real 'saved' playlist
1668 List<SonosEntry> playLists = getPlayLists();
1669 for (SonosEntry someList : playLists) {
1670 if (someList.getTitle().equals(queued.getTitle())) {
1671 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1672 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1677 } else if (queued.getUpnpClass().contains("object.container")) {
1678 // we are playing some other sort of
1679 // 'container' - we will save that to a
1680 // playlist for our convenience
1681 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1683 // save the playlist
1684 String existingList = "";
1685 List<SonosEntry> playLists = getPlayLists();
1686 for (SonosEntry someList : playLists) {
1687 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1688 existingList = someList.getId();
1693 saveQueue(TITLE_PREFIX + getUDN(), existingList);
1695 // get all the playlists and a ref to our
1697 playLists = getPlayLists();
1698 for (SonosEntry someList : playLists) {
1699 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1700 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1701 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1708 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1712 savedState.relTime = getRefreshedPosition();
1714 savedState.entry = null;
1720 * Restore the state (track, position etc) of the Sonos Zone player.
1722 * @return true if no error occurred.
1724 protected void restoreState() {
1725 synchronized (stateLock) {
1726 SonosZonePlayerState state = savedState;
1727 if (state != null) {
1728 // put settings back
1729 String volume = state.volume;
1730 if (volume != null) {
1731 setVolume(DecimalType.valueOf(volume));
1734 if (isCoordinator()) {
1735 SonosEntry entry = state.entry;
1736 if (entry != null) {
1737 // check if we have a playlist to deal with
1738 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1739 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1740 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1741 setCurrentURI(entry);
1742 setPositionTrack(state.track);
1744 setCurrentURI(entry);
1745 setPosition(state.relTime);
1749 String transportState = state.transportState;
1750 if (transportState != null) {
1751 if (transportState.equals(STATE_PLAYING)) {
1753 } else if (transportState.equals(STATE_STOPPED)) {
1755 } else if (transportState.equals(STATE_PAUSED_PLAYBACK)) {
1764 public void saveQueue(String name, String queueID) {
1765 executeAction(SERVICE_AV_TRANSPORT, ACTION_SAVE_QUEUE, Map.of("Title", name, "ObjectID", queueID));
1768 public void setVolume(Command command) {
1769 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1770 || command instanceof PercentType) {
1771 String newValue = null;
1772 String currentVolume = getVolume();
1773 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1774 int i = Integer.valueOf(currentVolume);
1775 newValue = String.valueOf(Math.min(100, i + 1));
1776 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1777 int i = Integer.valueOf(currentVolume);
1778 newValue = String.valueOf(Math.max(0, i - 1));
1779 } else if (command == OnOffType.ON) {
1781 } else if (command == OnOffType.OFF) {
1783 } else if (command instanceof DecimalType) {
1784 newValue = String.valueOf(((DecimalType) command).intValue());
1788 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_VOLUME,
1789 Map.of("Channel", "Master", "DesiredVolume", newValue));
1794 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1795 * AdHoc groups handles the volume specifically for each player.
1796 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1798 public void setVolumeForGroup(Command command) {
1799 if (isAdHocGroup() || isStandalonePlayer()) {
1803 getCoordinatorHandler().setVolume(command);
1804 } catch (IllegalStateException e) {
1805 logger.debug("Cannot set group volume ({})", e.getMessage());
1810 public void setBass(Command command) {
1811 if (!isOutputLevelFixed()) {
1812 String newValue = getNewNumericValue(command, getBass(), MIN_BASS, MAX_BASS);
1813 if (newValue != null) {
1814 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_BASS,
1815 Map.of("InstanceID", "0", "DesiredBass", newValue));
1820 public void setTreble(Command command) {
1821 if (!isOutputLevelFixed()) {
1822 String newValue = getNewNumericValue(command, getTreble(), MIN_TREBLE, MAX_TREBLE);
1823 if (newValue != null) {
1824 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_TREBLE,
1825 Map.of("InstanceID", "0", "DesiredTreble", newValue));
1830 private @Nullable String getNewNumericValue(Command command, @Nullable String currentValue, int minValue,
1832 String newValue = null;
1833 if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1834 if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1835 int i = Integer.valueOf(currentValue);
1836 newValue = String.valueOf(Math.min(maxValue, i + 1));
1837 } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1838 int i = Integer.valueOf(currentValue);
1839 newValue = String.valueOf(Math.max(minValue, i - 1));
1840 } else if (command instanceof DecimalType) {
1841 newValue = String.valueOf(((DecimalType) command).intValue());
1847 public void setLoudness(Command command) {
1848 if (!isOutputLevelFixed() && (command instanceof OnOffType || command instanceof OpenClosedType
1849 || command instanceof UpDownType)) {
1850 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1851 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
1852 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_LOUDNESS,
1853 Map.of("InstanceID", "0", "Channel", "Master", "DesiredLoudness", value));
1858 * Checks if the player receiving the command is part of a group that
1859 * consists of randomly added players or contains bonded players
1863 private boolean isAdHocGroup() {
1864 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1865 if (currentZoneGroup != null) {
1866 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1868 for (String zoneName : zoneGroupMemberNames) {
1869 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1870 // At least one "ZoneName" differs so we have an AdHoc group
1879 * Checks if the player receiving the command is a standalone player
1883 private boolean isStandalonePlayer() {
1884 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1885 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1888 private Collection<SonosZoneGroup> getZoneGroups() {
1889 String zoneGroupState = stateMap.get("ZoneGroupState");
1890 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1894 * Returns the current zone group
1895 * (of which the player receiving the command is part)
1897 * @return {@link SonosZoneGroup}
1899 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1900 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1901 if (zoneGroup.getMembers().contains(getUDN())) {
1905 logger.debug("Could not fetch Sonos group state information");
1910 * Sets the volume level for a notification sound
1912 * @param notificationSoundVolume
1914 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1915 if (notificationSoundVolume != null) {
1916 setVolumeForGroup(notificationSoundVolume);
1921 * Gets the volume level for a notification sound
1923 public @Nullable PercentType getNotificationSoundVolume() {
1924 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1925 if (notificationSoundVolume == null) {
1926 // if no value is set we use the current volume instead
1927 String volume = getVolume();
1928 return volume != null ? new PercentType(volume) : null;
1930 return new PercentType(notificationSoundVolume);
1933 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1934 Map<String, String> inputs = new HashMap<>();
1937 inputs.put("InstanceID", "0");
1938 inputs.put("EnqueuedURI", URI);
1939 inputs.put("EnqueuedURIMetaData", meta);
1940 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1941 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1942 } catch (NumberFormatException ex) {
1943 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1946 executeAction(SERVICE_AV_TRANSPORT, ACTION_ADD_URI_TO_QUEUE, inputs);
1949 public void setCurrentURI(SonosEntry newEntry) {
1950 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1953 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1954 if (URI != null && URIMetaData != null) {
1955 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1956 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_AV_TRANSPORT_URI,
1957 Map.of("InstanceID", "0", "CurrentURI", URI, "CurrentURIMetaData", URIMetaData));
1961 public void setPosition(@Nullable String relTime) {
1962 seek("REL_TIME", relTime);
1965 public void setPositionTrack(long tracknr) {
1966 seek("TRACK_NR", Long.toString(tracknr));
1969 public void setPositionTrack(String tracknr) {
1970 seek("TRACK_NR", tracknr);
1973 protected void seek(String unit, @Nullable String target) {
1974 if (target != null) {
1975 executeAction(SERVICE_AV_TRANSPORT, ACTION_SEEK, Map.of("InstanceID", "0", "Unit", unit, "Target", target));
1979 public void play() {
1980 executeAction(SERVICE_AV_TRANSPORT, ACTION_PLAY, Map.of("Speed", "1"));
1983 public void stop() {
1984 executeAction(SERVICE_AV_TRANSPORT, ACTION_STOP, null);
1987 public void pause() {
1988 executeAction(SERVICE_AV_TRANSPORT, ACTION_PAUSE, null);
1991 public void setShuffle(Command command) {
1992 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1994 ZonePlayerHandler coordinator = getCoordinatorHandler();
1996 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1997 || command.equals(OpenClosedType.OPEN)) {
1998 switch (coordinator.getRepeatMode()) {
2000 coordinator.updatePlayMode("SHUFFLE");
2003 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
2006 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
2009 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2010 || command.equals(OpenClosedType.CLOSED)) {
2011 switch (coordinator.getRepeatMode()) {
2013 coordinator.updatePlayMode("REPEAT_ALL");
2016 coordinator.updatePlayMode("REPEAT_ONE");
2019 coordinator.updatePlayMode("NORMAL");
2023 } catch (IllegalStateException e) {
2024 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
2029 public void setRepeat(Command command) {
2030 if (command instanceof StringType) {
2032 ZonePlayerHandler coordinator = getCoordinatorHandler();
2034 switch (command.toString()) {
2036 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
2039 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
2042 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
2045 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
2046 command.toString());
2049 } catch (IllegalStateException e) {
2050 logger.debug("Cannot handle repeat command ({})", e.getMessage());
2055 public void setSubwoofer(Command command) {
2056 setEqualizerBooleanSetting(command, "SubEnable");
2059 public void setSubwooferGain(Command command) {
2060 setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
2063 public void setSurround(Command command) {
2064 setEqualizerBooleanSetting(command, "SurroundEnable");
2067 public void setSurroundMusicMode(Command command) {
2068 if (command instanceof StringType) {
2069 setEQ("SurroundMode", command.toString());
2073 public void setSurroundMusicLevel(Command command) {
2074 setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
2075 MAX_SURROUND_LEVEL);
2078 public void setSurroundTvLevel(Command command) {
2079 setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
2080 MAX_SURROUND_LEVEL);
2083 public void setNightMode(Command command) {
2084 setEqualizerBooleanSetting(command, "NightMode");
2087 public void setSpeechEnhancement(Command command) {
2088 setEqualizerBooleanSetting(command, "DialogLevel");
2091 private void setEqualizerBooleanSetting(Command command, String eqType) {
2092 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2093 setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2094 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
2098 private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
2100 String newValue = getNewNumericValue(command, currentValue, minValue, maxValue);
2101 if (newValue != null) {
2102 setEQ(eqType, newValue);
2106 private void setEQ(String eqType, String value) {
2108 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_EQ,
2109 Map.of("InstanceID", "0", "EQType", eqType, "DesiredValue", value));
2110 } catch (IllegalStateException e) {
2111 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2115 public @Nullable String getNightMode() {
2116 return stateMap.get("NightMode");
2119 public @Nullable String getDialogLevel() {
2120 return stateMap.get("DialogLevel");
2123 public @Nullable String getPlayMode() {
2124 return stateMap.get("CurrentPlayMode");
2127 public Boolean isShuffleActive() {
2128 String playMode = getPlayMode();
2129 return (playMode != null && playMode.startsWith("SHUFFLE"));
2132 public String getRepeatMode() {
2133 String mode = "OFF";
2134 String playMode = getPlayMode();
2135 if (playMode != null) {
2142 case "SHUFFLE_REPEAT_ONE":
2146 case "SHUFFLE_NOREPEAT":
2155 public @Nullable String getMicEnabled() {
2156 return stateMap.get("MicEnabled");
2159 protected void updatePlayMode(String playMode) {
2160 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_PLAY_MODE, Map.of("InstanceID", "0", "NewPlayMode", playMode));
2164 * Clear all scheduled music from the current queue.
2167 public void removeAllTracksFromQueue() {
2168 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE, Map.of("InstanceID", "0"));
2172 * Play music from the line-in of the given Player referenced by the given UDN or name
2174 * @param udn or name
2176 public void playLineIn(Command command) {
2177 if (command instanceof StringType) {
2179 LineInType lineInType = LineInType.ANY;
2180 String remotePlayerName = command.toString();
2181 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2182 lineInType = LineInType.ANALOG;
2183 remotePlayerName = remotePlayerName.substring(7);
2184 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2185 lineInType = LineInType.DIGITAL;
2186 remotePlayerName = remotePlayerName.substring(8);
2188 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2189 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2191 // check if player has a line-in connected
2192 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2193 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2194 // stop whatever is currently playing
2195 coordinatorHandler.stop();
2198 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2199 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2201 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2204 // take the system off mute
2205 coordinatorHandler.setMute(OnOffType.OFF);
2208 coordinatorHandler.play();
2210 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2212 } catch (IllegalStateException e) {
2213 logger.debug("Cannot play line-in ({})", e.getMessage());
2218 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2219 ZonePlayerHandler handler = coordinatorHandler;
2220 if (handler != null) {
2224 handler = getHandlerByName(getCoordinator());
2225 coordinatorHandler = handler;
2227 } catch (IllegalStateException e) {
2228 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2233 * Returns a list of all zone group members this particular player is member of
2234 * Or empty list if the players is not assigned to any group
2236 * @return a list of Strings containing the UDNs of other group members
2238 protected List<String> getZoneGroupMembers() {
2239 List<String> result = new ArrayList<>();
2241 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2242 if (!zoneGroups.isEmpty()) {
2243 for (SonosZoneGroup zg : zoneGroups) {
2244 if (zg.getMembers().contains(getUDN())) {
2245 result.addAll(zg.getMembers());
2250 // If the group topology was not yet received, return at least the current Sonos zone
2251 result.add(getUDN());
2257 * Returns a list of other zone group members this particular player is member of
2258 * Or empty list if the players is not assigned to any group
2260 * @return a list of Strings containing the UDNs of other group members
2262 protected List<String> getOtherZoneGroupMembers() {
2263 List<String> zoneGroupMembers = getZoneGroupMembers();
2264 zoneGroupMembers.remove(getUDN());
2265 return zoneGroupMembers;
2268 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2269 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2270 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2271 if (thing != null) {
2272 ThingHandler handler = thing.getHandler();
2273 if (handler instanceof ZonePlayerHandler) {
2274 return (ZonePlayerHandler) handler;
2278 for (Thing aThing : localThingRegistry.getAll()) {
2279 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2280 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2281 ThingHandler handler = aThing.getHandler();
2282 if (handler instanceof ZonePlayerHandler) {
2283 return (ZonePlayerHandler) handler;
2287 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2290 public void setMute(Command command) {
2291 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2292 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2293 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
2294 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_MUTE,
2295 Map.of("Channel", "Master", "DesiredMute", value));
2299 public List<SonosAlarm> getCurrentAlarmList() {
2300 Map<String, String> result = executeAction(SERVICE_ALARM_CLOCK, "ListAlarms", null);
2301 String alarmList = result.get("CurrentAlarmList");
2302 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2305 public void updateAlarm(SonosAlarm alarm) {
2306 Map<String, String> inputs = new HashMap<>();
2309 inputs.put("ID", Integer.toString(alarm.getId()));
2310 inputs.put("StartLocalTime", alarm.getStartTime());
2311 inputs.put("Duration", alarm.getDuration());
2312 inputs.put("Recurrence", alarm.getRecurrence());
2313 inputs.put("RoomUUID", alarm.getRoomUUID());
2314 inputs.put("ProgramURI", alarm.getProgramURI());
2315 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2316 inputs.put("PlayMode", alarm.getPlayMode());
2317 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2318 if (alarm.getIncludeLinkedZones()) {
2319 inputs.put("IncludeLinkedZones", "1");
2321 inputs.put("IncludeLinkedZones", "0");
2324 if (alarm.getEnabled()) {
2325 inputs.put("Enabled", "1");
2327 inputs.put("Enabled", "0");
2329 } catch (NumberFormatException ex) {
2330 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2333 executeAction(SERVICE_ALARM_CLOCK, "UpdateAlarm", inputs);
2336 public void setAlarm(Command command) {
2337 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2338 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2340 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2341 || command.equals(OpenClosedType.CLOSED)) {
2347 public void setAlarm(boolean alarmSwitch) {
2348 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2350 // find the nearest alarm - take the current time from the Sonos system,
2351 // not the system where we are running
2352 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2353 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2355 String currentLocalTime = getTime();
2356 Date currentDateTime = null;
2358 currentDateTime = fmt.parse(currentLocalTime);
2359 } catch (ParseException e) {
2360 logger.debug("An exception occurred while formatting a date", e);
2363 if (currentDateTime != null) {
2364 Calendar currentDateTimeCalendar = Calendar.getInstance();
2365 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2366 currentDateTimeCalendar.setTime(currentDateTime);
2367 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2368 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2370 SonosAlarm firstAlarm = null;
2372 for (SonosAlarm anAlarm : sonosAlarms) {
2373 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2374 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2377 durationDate = durationFormat.parse(anAlarm.getDuration());
2378 } catch (ParseException e) {
2379 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2383 long duration = durationDate.getTime();
2385 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2386 shortestDuration = duration;
2387 firstAlarm = anAlarm;
2392 if (firstAlarm != null) {
2394 firstAlarm.setEnabled(true);
2396 firstAlarm.setEnabled(false);
2399 updateAlarm(firstAlarm);
2404 public @Nullable String getTime() {
2406 return stateMap.get("CurrentLocalTime");
2409 public @Nullable String getAlarmRunning() {
2410 return stateMap.get("AlarmRunning");
2413 public boolean isAlarmRunning() {
2414 return "1".equals(getAlarmRunning());
2417 public void snoozeAlarm(Command command) {
2418 if (isAlarmRunning() && command instanceof DecimalType) {
2419 int minutes = ((DecimalType) command).intValue();
2421 Map<String, String> inputs = new HashMap<>();
2423 Calendar snoozePeriod = Calendar.getInstance();
2424 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2425 snoozePeriod.setTimeInMillis(0);
2426 snoozePeriod.add(Calendar.MINUTE, minutes);
2427 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2428 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2431 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2432 } catch (NumberFormatException ex) {
2433 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2436 executeAction(SERVICE_AV_TRANSPORT, ACTION_SNOOZE_ALARM, inputs);
2438 logger.debug("There is no alarm running on {}", getUDN());
2442 public @Nullable String getAnalogLineInConnected() {
2443 return stateMap.get(LINEINCONNECTED);
2446 public boolean isAnalogLineInConnected() {
2447 return "true".equals(getAnalogLineInConnected());
2450 public @Nullable String getOpticalLineInConnected() {
2451 return stateMap.get(TOSLINEINCONNECTED);
2454 public boolean isOpticalLineInConnected() {
2455 return "true".equals(getOpticalLineInConnected());
2458 public void becomeStandAlonePlayer() {
2459 executeAction(SERVICE_AV_TRANSPORT, ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP, null);
2462 public void addMember(Command command) {
2463 if (command instanceof StringType) {
2464 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2466 getHandlerByName(command.toString()).setCurrentURI(entry);
2467 } catch (IllegalStateException e) {
2468 logger.debug("Cannot add group member ({})", e.getMessage());
2473 public boolean publicAddress(LineInType lineInType) {
2474 // check if sourcePlayer has a line-in connected
2475 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2476 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2477 // first remove this player from its own group if any
2478 becomeStandAlonePlayer();
2480 // add all other players to this new group
2481 for (SonosZoneGroup group : getZoneGroups()) {
2482 for (String player : group.getMembers()) {
2484 ZonePlayerHandler somePlayer = getHandlerByName(player);
2485 if (somePlayer != this) {
2486 somePlayer.becomeStandAlonePlayer();
2488 addMember(StringType.valueOf(somePlayer.getUDN()));
2490 } catch (IllegalStateException e) {
2491 logger.debug("Cannot add to group ({})", e.getMessage());
2497 ZonePlayerHandler coordinator = getCoordinatorHandler();
2498 // set the URI of the group to the line-in
2499 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2500 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2501 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2503 coordinator.setCurrentURI(entry);
2507 } catch (IllegalStateException e) {
2508 logger.debug("Cannot handle command ({})", e.getMessage());
2512 logger.debug("Line-in of {} is not connected", getUDN());
2518 * Play a given url to music in one of the music libraries.
2521 * in the format of //host/folder/filename.mp3
2523 public void playURI(Command command) {
2524 if (command instanceof StringType) {
2526 String url = command.toString();
2528 ZonePlayerHandler coordinator = getCoordinatorHandler();
2530 // stop whatever is currently playing
2532 coordinator.waitForNotTransportState(STATE_PLAYING);
2534 // clear any tracks which are pending in the queue
2535 coordinator.removeAllTracksFromQueue();
2537 // add the new track we want to play to the queue
2538 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2539 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2540 // default to file based url
2541 url = FILE_URI + url;
2543 coordinator.addURIToQueue(url, "", 0, true);
2545 // set the current playlist to our new queue
2546 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2548 // take the system off mute
2549 coordinator.setMute(OnOffType.OFF);
2553 } catch (IllegalStateException e) {
2554 logger.debug("Cannot play URI ({})", e.getMessage());
2559 private void scheduleNotificationSound(final Command command) {
2560 scheduler.submit(() -> {
2561 synchronized (notificationLock) {
2562 playNotificationSoundURI(command);
2568 * Play a given notification sound
2570 * @param url in the format of //host/folder/filename.mp3
2572 public void playNotificationSoundURI(Command notificationURL) {
2573 if (notificationURL instanceof StringType) {
2575 ZonePlayerHandler coordinator = getCoordinatorHandler();
2577 String currentURI = coordinator.getCurrentURI();
2578 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2579 coordinator.getCurrentURIMetadataAsString());
2581 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2582 || isPlayingRadio(currentURI)) {
2583 handleRadioStream(currentURI, notificationURL, coordinator);
2584 } else if (isPlayingLineIn(currentURI)) {
2585 handleLineIn(currentURI, notificationURL, coordinator);
2586 } else if (isPlayingQueue(currentURI)) {
2587 handleSharedQueue(currentURI, notificationURL, coordinator);
2588 } else if (isPlaylistEmpty(coordinator)) {
2589 handleEmptyQueue(notificationURL, coordinator);
2591 synchronized (notificationLock) {
2592 notificationLock.notify();
2594 } catch (IllegalStateException e) {
2595 logger.debug("Cannot play sound ({})", e.getMessage());
2600 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2601 return coordinator.getQueueSize() == 0;
2604 private boolean isPlayingQueue(@Nullable String currentURI) {
2605 return currentURI != null && currentURI.contains(QUEUE_URI);
2608 private boolean isPlayingStream(@Nullable String currentURI) {
2609 return currentURI != null && currentURI.contains(STREAM_URI);
2612 private boolean isPlayingRadio(@Nullable String currentURI) {
2613 return currentURI != null && currentURI.contains(RADIO_URI);
2616 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2617 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2620 private boolean isPlayingLineIn(@Nullable String currentURI) {
2621 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2624 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2625 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2628 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2629 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2633 * Does a chain of predefined actions when a Notification sound is played by
2634 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2635 * radio streaming is currently loaded
2637 * @param currentStreamURI - the currently loaded stream's URI
2638 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2639 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2641 private void handleRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2642 ZonePlayerHandler coordinator) {
2643 String nextAction = coordinator.getTransportState();
2644 SonosMetaData track = coordinator.getTrackMetadata();
2645 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2647 handleNotificationSound(notificationURL, coordinator);
2648 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2649 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2650 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2651 restoreLastTransportState(coordinator, nextAction);
2656 * Does a chain of predefined actions when a Notification sound is played by
2657 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2658 * line in is currently loaded
2660 * @param currentLineInURI - the currently loaded line-in URI
2661 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2662 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2664 private void handleLineIn(@Nullable String currentLineInURI, Command notificationURL,
2665 ZonePlayerHandler coordinator) {
2666 logger.debug("Handling notification while sound from line-in was being played");
2667 String nextAction = coordinator.getTransportState();
2669 handleNotificationSound(notificationURL, coordinator);
2670 if (currentLineInURI != null) {
2671 logger.debug("Restoring sound from line-in using {}", currentLineInURI);
2672 coordinator.setCurrentURI(currentLineInURI, "");
2673 restoreLastTransportState(coordinator, nextAction);
2678 * Does a chain of predefined actions when a Notification sound is played by
2679 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2680 * shared queue is currently loaded
2682 * @param currentQueueURI - the currently loaded queue URI
2683 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2684 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2686 private void handleSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2687 ZonePlayerHandler coordinator) {
2688 String nextAction = coordinator.getTransportState();
2689 String trackPosition = coordinator.getRefreshedPosition();
2690 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2691 logger.debug("handleSharedQueue: currentQueueURI {} trackPosition {} currentTrackNumber {}", currentQueueURI,
2692 trackPosition, currentTrackNumber);
2694 handleNotificationSound(notificationURL, coordinator);
2695 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2696 if (queueUri.equals(currentQueueURI)) {
2697 coordinator.setPositionTrack(currentTrackNumber);
2698 coordinator.setPosition(trackPosition);
2699 restoreLastTransportState(coordinator, nextAction);
2704 * Handle the execution of the notification sound by sequentially executing the required steps.
2706 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2707 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2709 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator) {
2710 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2711 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2712 if (sourceStoppable) {
2714 coordinator.waitForNotTransportState(STATE_PLAYING);
2715 applyNotificationSoundVolume();
2717 long notificationPosition = coordinator.getQueueSize() + 1;
2718 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2719 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2720 coordinator.setPositionTrack(notificationPosition);
2721 if (!sourceStoppable) {
2723 coordinator.waitForNotTransportState(STATE_PLAYING);
2724 applyNotificationSoundVolume();
2727 coordinator.waitForFinishedNotification();
2728 if (originalVolume != null) {
2729 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2731 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2734 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction) {
2735 if (nextAction != null) {
2736 switch (nextAction) {
2739 coordinator.waitForTransportState(STATE_PLAYING);
2741 case STATE_PAUSED_PLAYBACK:
2742 coordinator.pause();
2749 * Does a chain of predefined actions when a Notification sound is played by
2750 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2751 * empty queue is currently loaded
2753 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2754 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2756 private void handleEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator) {
2757 String originalVolume = coordinator.getVolume();
2758 coordinator.applyNotificationSoundVolume();
2759 coordinator.playURI(notificationURL);
2760 coordinator.waitForFinishedNotification();
2761 coordinator.removeAllTracksFromQueue();
2762 if (originalVolume != null) {
2763 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2768 * Applies the notification sound volume level to the group (if not null)
2770 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2772 private void applyNotificationSoundVolume() {
2773 setNotificationSoundVolume(getNotificationSoundVolume());
2776 private void waitForFinishedNotification() {
2777 waitForTransportState(STATE_PLAYING);
2779 // check Sonos state events to determine the end of the notification sound
2780 String notificationTitle = getCurrentTitle();
2781 long playstart = System.currentTimeMillis();
2782 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2785 String currentTitle = getCurrentTitle();
2786 if ((notificationTitle == null && currentTitle != null)
2787 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2788 || !STATE_PLAYING.equals(getTransportState())) {
2791 } catch (InterruptedException e) {
2792 logger.debug("InterruptedException during playing a notification sound");
2797 private void waitForTransportState(String state) {
2798 if (getTransportState() != null) {
2799 long start = System.currentTimeMillis();
2800 while (!state.equals(getTransportState())) {
2803 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2806 } catch (InterruptedException e) {
2807 logger.debug("InterruptedException during playing a notification sound");
2813 private void waitForNotTransportState(String state) {
2814 if (getTransportState() != null) {
2815 long start = System.currentTimeMillis();
2816 while (state.equals(getTransportState())) {
2819 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2822 } catch (InterruptedException e) {
2823 logger.debug("InterruptedException during playing a notification sound");
2830 * Removes a range of tracks from the queue.
2831 * (<x,y> will remove y songs started by the song number x)
2833 * @param command - must be in the format <startIndex, numberOfSongs>
2835 public void removeRangeOfTracksFromQueue(Command command) {
2836 if (command instanceof StringType) {
2837 String[] rangeInputSplit = command.toString().split(",");
2838 // If range input is incorrect, remove the first song by default
2839 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2840 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2841 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE,
2842 Map.of("InstanceID", "0", "StartingIndex", startIndex, "NumberOfTracks", numberOfTracks));
2846 public void clearQueue() {
2848 ZonePlayerHandler coordinator = getCoordinatorHandler();
2850 coordinator.removeAllTracksFromQueue();
2851 } catch (IllegalStateException e) {
2852 logger.debug("Cannot clear queue ({})", e.getMessage());
2856 public void playQueue() {
2858 ZonePlayerHandler coordinator = getCoordinatorHandler();
2860 // set the current playlist to our new queue
2861 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2863 // take the system off mute
2864 coordinator.setMute(OnOffType.OFF);
2868 } catch (IllegalStateException e) {
2869 logger.debug("Cannot play queue ({})", e.getMessage());
2873 public void setLed(Command command) {
2874 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2875 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2876 || command.equals(OpenClosedType.OPEN)) ? "On" : "Off";
2877 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_SET_LED_STATE, Map.of("DesiredLEDState", value));
2878 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
2882 public void removeMember(Command command) {
2883 if (command instanceof StringType) {
2885 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2887 oldmemberHandler.becomeStandAlonePlayer();
2888 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2889 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2890 oldmemberHandler.setCurrentURI(entry);
2891 } catch (IllegalStateException e) {
2892 logger.debug("Cannot remove group member ({})", e.getMessage());
2897 public void previous() {
2898 executeAction(SERVICE_AV_TRANSPORT, ACTION_PREVIOUS, null);
2901 public void next() {
2902 executeAction(SERVICE_AV_TRANSPORT, ACTION_NEXT, null);
2905 public void stopPlaying(Command command) {
2906 if (command instanceof OnOffType) {
2908 getCoordinatorHandler().stop();
2909 } catch (IllegalStateException e) {
2910 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2915 public void playRadio(Command command) {
2916 if (command instanceof StringType) {
2917 String station = command.toString();
2918 List<SonosEntry> stations = getFavoriteRadios();
2920 SonosEntry theEntry = null;
2921 // search for the appropriate radio based on its name (title)
2922 for (SonosEntry someStation : stations) {
2923 if (someStation.getTitle().equals(station)) {
2924 theEntry = someStation;
2929 // set the URI of the group coordinator
2930 if (theEntry != null) {
2932 ZonePlayerHandler coordinator = getCoordinatorHandler();
2933 coordinator.setCurrentURI(theEntry);
2935 } catch (IllegalStateException e) {
2936 logger.debug("Cannot play radio ({})", e.getMessage());
2939 logger.debug("Radio station '{}' not found", station);
2944 public void playTuneinStation(Command command) {
2945 if (command instanceof StringType) {
2946 String stationId = command.toString();
2947 List<SonosMusicService> allServices = getAvailableMusicServices();
2949 SonosMusicService tuneinService = null;
2950 // search for the TuneIn music service based on its name
2951 if (allServices != null) {
2952 for (SonosMusicService service : allServices) {
2953 if (service.getName().equals("TuneIn")) {
2954 tuneinService = service;
2960 // set the URI of the group coordinator
2961 if (tuneinService != null) {
2963 ZonePlayerHandler coordinator = getCoordinatorHandler();
2964 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
2965 "object.item.audioItem.audioBroadcast",
2966 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
2967 Integer tuneinServiceType = tuneinService.getType();
2968 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
2969 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
2970 coordinator.setCurrentURI(entry);
2972 } catch (IllegalStateException e) {
2973 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
2976 logger.debug("TuneIn service not found");
2981 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
2982 if (musicServices == null) {
2983 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
2985 String serviceList = result.get("AvailableServiceDescriptorList");
2986 if (serviceList != null) {
2987 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
2988 musicServices = services;
2990 String[] servicesTypes = new String[0];
2991 String serviceTypeList = result.get("AvailableServiceTypeList");
2992 if (serviceTypeList != null) {
2993 // It is a comma separated list of service types (integers) in the same order as the services
2994 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
2996 servicesTypes = serviceTypeList.split(",");
3000 for (SonosMusicService service : services) {
3001 if (!service.getName().equals("TuneIn")) {
3002 // Add the service type integer value from "AvailableServiceTypeList" to each service
3004 if (idx < servicesTypes.length) {
3006 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
3007 service.setType(serviceType);
3008 } catch (NumberFormatException e) {
3013 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3015 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3020 return musicServices;
3024 * This will attempt to match the station string with a entry in the
3025 * favorites list, this supports both single entries and playlists
3027 * @param favorite to match
3028 * @return true if a match was found and played.
3030 public void playFavorite(Command command) {
3031 if (command instanceof StringType) {
3032 String favorite = command.toString();
3033 List<SonosEntry> favorites = getFavorites();
3035 SonosEntry theEntry = null;
3036 // search for the appropriate favorite based on its name (title)
3037 for (SonosEntry entry : favorites) {
3038 if (entry.getTitle().equals(favorite)) {
3044 // set the URI of the group coordinator
3045 if (theEntry != null) {
3047 ZonePlayerHandler coordinator = getCoordinatorHandler();
3050 * If this is a playlist we need to treat it as such
3052 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3053 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3054 coordinator.removeAllTracksFromQueue();
3055 coordinator.addURIToQueue(theEntry);
3056 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3057 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3058 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3060 coordinator.setCurrentURI(theEntry);
3063 } catch (IllegalStateException e) {
3064 logger.debug("Cannot paly favorite ({})", e.getMessage());
3067 logger.debug("Favorite '{}' not found", favorite);
3072 public void playTrack(Command command) {
3073 if (command instanceof DecimalType) {
3075 ZonePlayerHandler coordinator = getCoordinatorHandler();
3077 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3079 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3081 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3082 coordinator.setPositionTrack(trackNumber);
3084 // take the system off mute
3085 coordinator.setMute(OnOffType.OFF);
3089 } catch (IllegalStateException e) {
3090 logger.debug("Cannot play track ({})", e.getMessage());
3095 public void playPlayList(Command command) {
3096 if (command instanceof StringType) {
3097 String playlist = command.toString();
3098 List<SonosEntry> playlists = getPlayLists();
3100 SonosEntry theEntry = null;
3101 // search for the appropriate play list based on its name (title)
3102 for (SonosEntry somePlaylist : playlists) {
3103 if (somePlaylist.getTitle().equals(playlist)) {
3104 theEntry = somePlaylist;
3109 // set the URI of the group coordinator
3110 if (theEntry != null) {
3112 ZonePlayerHandler coordinator = getCoordinatorHandler();
3114 coordinator.addURIToQueue(theEntry);
3116 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3118 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3119 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3122 } catch (IllegalStateException e) {
3123 logger.debug("Cannot play playlist ({})", e.getMessage());
3126 logger.debug("Playlist '{}' not found", playlist);
3131 public void addURIToQueue(SonosEntry newEntry) {
3132 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3135 public @Nullable String getZoneName() {
3136 return stateMap.get("ZoneName");
3139 public @Nullable String getZoneGroupID() {
3140 return stateMap.get("LocalGroupUUID");
3143 public @Nullable String getRunningAlarmProperties() {
3144 return stateMap.get("RunningAlarmProperties");
3147 public @Nullable String getRefreshedRunningAlarmProperties() {
3148 updateRunningAlarmProperties();
3149 return getRunningAlarmProperties();
3152 public @Nullable String getMute() {
3153 return stateMap.get("MuteMaster");
3156 public @Nullable String getLed() {
3157 return stateMap.get("CurrentLEDState");
3160 public @Nullable String getCurrentZoneName() {
3161 return stateMap.get("CurrentZoneName");
3164 public @Nullable String getRefreshedCurrentZoneName() {
3165 updateCurrentZoneName();
3166 return getCurrentZoneName();
3170 public void onStatusChanged(boolean status) {
3172 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3173 if (getThing().getStatus() != ThingStatus.ONLINE) {
3174 updateStatus(ThingStatus.ONLINE);
3175 scheduler.execute(this::poll);
3178 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3179 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3183 private @Nullable String getModelNameFromDescriptor() {
3184 URL descriptor = service.getDescriptorURL(this);
3185 if (descriptor != null) {
3186 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3187 return sonosModelDescription == null ? null : SonosXMLParser.extractModelName(sonosModelDescription);
3193 private boolean migrateThingType() {
3194 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3195 String modelName = getModelNameFromDescriptor();
3196 if (modelName != null && isSupportedModel(modelName)) {
3197 updateSonosThingType(modelName);
3204 private boolean isSupportedModel(String modelName) {
3205 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3206 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3213 private void updateSonosThingType(String newThingTypeID) {
3214 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3218 * Set the sleeptimer duration
3219 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3220 * Use empty String "" to switch the sleep timer off
3222 public void setSleepTimer(Command command) {
3223 if (command instanceof DecimalType) {
3224 this.service.invokeAction(this, SERVICE_AV_TRANSPORT, ACTION_CONFIGURE_SLEEP_TIMER, Map.of("InstanceID",
3225 "0", "NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue())));
3229 protected void updateSleepTimerDuration() {
3230 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_REMAINING_SLEEP_TIMER_DURATION, null);
3233 private String sleepSecondsToTimeStr(long sleepSeconds) {
3234 if (sleepSeconds == 0) {
3236 } else if (sleepSeconds < 68400) {
3237 long remainingSeconds = sleepSeconds;
3238 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3239 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3240 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3241 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3242 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3243 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3245 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3250 private long sleepStrTimeToSeconds(String sleepTime) {
3251 String[] units = sleepTime.split(":");
3252 int hours = Integer.parseInt(units[0]);
3253 int minutes = Integer.parseInt(units[1]);
3254 int seconds = Integer.parseInt(units[2]);
3255 return 3600 * hours + 60 * minutes + seconds;
3258 private @Nullable String extractInfoFromMoreInfo(String searchedInfo) {
3259 String value = stateMap.get("MoreInfo");
3260 if (value != null) {
3261 String[] fields = value.split(",");
3262 for (int i = 0; i < fields.length; i++) {
3263 String[] pair = fields[i].trim().split(":");
3264 if (pair.length == 2 && searchedInfo.equalsIgnoreCase(pair[0].trim())) {
3265 return pair[1].trim();