2 * Copyright (c) 2010-2022 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.net.MalformedURLException;
19 import java.text.ParseException;
20 import java.text.SimpleDateFormat;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Calendar;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.Date;
27 import java.util.HashMap;
28 import java.util.List;
30 import java.util.TimeZone;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.sonos.internal.SonosAlarm;
37 import org.openhab.binding.sonos.internal.SonosBindingConstants;
38 import org.openhab.binding.sonos.internal.SonosEntry;
39 import org.openhab.binding.sonos.internal.SonosMetaData;
40 import org.openhab.binding.sonos.internal.SonosMusicService;
41 import org.openhab.binding.sonos.internal.SonosResourceMetaData;
42 import org.openhab.binding.sonos.internal.SonosStateDescriptionOptionProvider;
43 import org.openhab.binding.sonos.internal.SonosXMLParser;
44 import org.openhab.binding.sonos.internal.SonosZoneGroup;
45 import org.openhab.binding.sonos.internal.SonosZonePlayerState;
46 import org.openhab.binding.sonos.internal.config.ZonePlayerConfiguration;
47 import org.openhab.core.io.net.http.HttpUtil;
48 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
49 import org.openhab.core.io.transport.upnp.UpnpIOService;
50 import org.openhab.core.library.types.DecimalType;
51 import org.openhab.core.library.types.IncreaseDecreaseType;
52 import org.openhab.core.library.types.NextPreviousType;
53 import org.openhab.core.library.types.OnOffType;
54 import org.openhab.core.library.types.OpenClosedType;
55 import org.openhab.core.library.types.PercentType;
56 import org.openhab.core.library.types.PlayPauseType;
57 import org.openhab.core.library.types.RawType;
58 import org.openhab.core.library.types.StringType;
59 import org.openhab.core.library.types.UpDownType;
60 import org.openhab.core.thing.ChannelUID;
61 import org.openhab.core.thing.Thing;
62 import org.openhab.core.thing.ThingRegistry;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.ThingTypeUID;
66 import org.openhab.core.thing.ThingUID;
67 import org.openhab.core.thing.binding.BaseThingHandler;
68 import org.openhab.core.thing.binding.ThingHandler;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.RefreshType;
71 import org.openhab.core.types.State;
72 import org.openhab.core.types.StateOption;
73 import org.openhab.core.types.UnDefType;
74 import org.slf4j.Logger;
75 import org.slf4j.LoggerFactory;
78 * The {@link ZonePlayerHandler} is responsible for handling commands, which are
79 * sent to one of the channels.
81 * @author Karel Goderis - Initial contribution
84 public class ZonePlayerHandler extends BaseThingHandler implements UpnpIOParticipant {
86 private static final String ANALOG_LINE_IN_URI = "x-rincon-stream:";
87 private static final String OPTICAL_LINE_IN_URI = "x-sonos-htastream:";
88 private static final String VIRTUAL_LINE_IN_URI = "x-sonos-vli:";
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 RADIOAPP_URI = "x-sonosapi-hls:radioapp_";
95 private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
96 private static final String FILE_URI = "x-file-cifs:";
97 private static final String SPDIF = ":spdif";
98 private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
100 private static final String STATE_PLAYING = "PLAYING";
101 private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
102 private static final String STATE_STOPPED = "STOPPED";
104 private static final String LINEINCONNECTED = "LineInConnected";
105 private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
107 private static final String SERVICE_DEVICE_PROPERTIES = "DeviceProperties";
108 private static final String SERVICE_AV_TRANSPORT = "AVTransport";
109 private static final String SERVICE_RENDERING_CONTROL = "RenderingControl";
110 private static final String SERVICE_ZONE_GROUP_TOPOLOGY = "ZoneGroupTopology";
111 private static final String SERVICE_GROUP_MANAGEMENT = "GroupManagement";
112 private static final String SERVICE_AUDIO_IN = "AudioIn";
113 private static final String SERVICE_HT_CONTROL = "HTControl";
114 private static final String SERVICE_CONTENT_DIRECTORY = "ContentDirectory";
115 private static final String SERVICE_ALARM_CLOCK = "AlarmClock";
117 private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList(SERVICE_DEVICE_PROPERTIES,
118 SERVICE_AV_TRANSPORT, SERVICE_ZONE_GROUP_TOPOLOGY, SERVICE_GROUP_MANAGEMENT, SERVICE_RENDERING_CONTROL,
119 SERVICE_AUDIO_IN, SERVICE_HT_CONTROL, SERVICE_CONTENT_DIRECTORY);
120 protected static final int SUBSCRIPTION_DURATION = 1800;
122 private static final String ACTION_GET_ZONE_ATTRIBUTES = "GetZoneAttributes";
123 private static final String ACTION_GET_ZONE_INFO = "GetZoneInfo";
124 private static final String ACTION_GET_LED_STATE = "GetLEDState";
125 private static final String ACTION_SET_LED_STATE = "SetLEDState";
127 private static final String ACTION_GET_POSITION_INFO = "GetPositionInfo";
128 private static final String ACTION_SET_AV_TRANSPORT_URI = "SetAVTransportURI";
129 private static final String ACTION_SEEK = "Seek";
130 private static final String ACTION_PLAY = "Play";
131 private static final String ACTION_STOP = "Stop";
132 private static final String ACTION_PAUSE = "Pause";
133 private static final String ACTION_PREVIOUS = "Previous";
134 private static final String ACTION_NEXT = "Next";
135 private static final String ACTION_ADD_URI_TO_QUEUE = "AddURIToQueue";
136 private static final String ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE = "RemoveTrackRangeFromQueue";
137 private static final String ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE = "RemoveAllTracksFromQueue";
138 private static final String ACTION_SAVE_QUEUE = "SaveQueue";
139 private static final String ACTION_SET_PLAY_MODE = "SetPlayMode";
140 private static final String ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP = "BecomeCoordinatorOfStandaloneGroup";
141 private static final String ACTION_GET_RUNNING_ALARM_PROPERTIES = "GetRunningAlarmProperties";
142 private static final String ACTION_SNOOZE_ALARM = "SnoozeAlarm";
143 private static final String ACTION_GET_REMAINING_SLEEP_TIMER_DURATION = "GetRemainingSleepTimerDuration";
144 private static final String ACTION_CONFIGURE_SLEEP_TIMER = "ConfigureSleepTimer";
146 private static final String ACTION_SET_VOLUME = "SetVolume";
147 private static final String ACTION_SET_MUTE = "SetMute";
148 private static final String ACTION_SET_BASS = "SetBass";
149 private static final String ACTION_SET_TREBLE = "SetTreble";
150 private static final String ACTION_SET_LOUDNESS = "SetLoudness";
151 private static final String ACTION_SET_EQ = "SetEQ";
153 private static final int TUNEIN_DEFAULT_SERVICE_TYPE = 65031;
155 private static final int MIN_BASS = -10;
156 private static final int MAX_BASS = 10;
157 private static final int MIN_TREBLE = -10;
158 private static final int MAX_TREBLE = 10;
159 private static final int MIN_SUBWOOFER_GAIN = -15;
160 private static final int MAX_SUBWOOFER_GAIN = 15;
161 private static final int MIN_SURROUND_LEVEL = -15;
162 private static final int MAX_SURROUND_LEVEL = 15;
163 private static final int MIN_HEIGHT_LEVEL = -10;
164 private static final int MAX_HEIGHT_LEVEL = 10;
166 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
168 private final ThingRegistry localThingRegistry;
169 private final UpnpIOService service;
170 private final @Nullable String opmlUrl;
171 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
173 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
176 * Intrinsic lock used to synchronize the execution of notification sounds
178 private final Object notificationLock = new Object();
179 private final Object upnpLock = new Object();
180 private final Object stateLock = new Object();
181 private final Object jobLock = new Object();
183 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
185 private @Nullable ScheduledFuture<?> pollingJob;
186 private @Nullable SonosZonePlayerState savedState;
188 private Map<String, Boolean> subscriptionState = new HashMap<>();
191 * Thing handler instance of the coordinator speaker used for control delegation
193 private @Nullable ZonePlayerHandler coordinatorHandler;
195 private @Nullable List<SonosMusicService> musicServices;
197 private enum LineInType {
203 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
204 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
206 this.localThingRegistry = thingRegistry;
207 this.opmlUrl = opmlUrl;
208 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
209 this.service = upnpIOService;
210 this.stateDescriptionProvider = stateDescriptionProvider;
214 public void dispose() {
215 logger.debug("Handler disposed for thing {}", getThing().getUID());
217 ScheduledFuture<?> job = this.pollingJob;
221 this.pollingJob = null;
223 removeSubscription();
224 service.unregisterParticipant(this);
228 public void initialize() {
229 logger.debug("initializing handler for thing {}", getThing().getUID());
231 if (migrateThingType()) {
232 // we change the type, so we might need a different handler -> let's finish
236 configuration = getConfigAs(ZonePlayerConfiguration.class);
237 String udn = configuration.udn;
238 if (udn != null && !udn.isEmpty()) {
239 service.registerParticipant(this);
240 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
242 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
243 "@text/offline.conf-error-missing-udn");
244 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
248 private void poll() {
249 synchronized (jobLock) {
250 if (pollingJob == null) {
254 logger.debug("Polling job");
256 // First check if the Sonos zone is set in the UPnP service registry
257 // If not, set the thing state to OFFLINE and wait for the next poll
258 if (!isUpnpDeviceRegistered()) {
259 logger.debug("UPnP device {} not yet registered", getUDN());
260 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
261 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
262 synchronized (upnpLock) {
263 subscriptionState = new HashMap<>();
268 // Check if the Sonos zone can be joined
269 // If not, set the thing state to OFFLINE and do nothing else
271 if (getThing().getStatus() != ThingStatus.ONLINE) {
277 if (isLinked(ZONENAME)) {
278 updateCurrentZoneName();
283 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
284 if (isLinked(SLEEPTIMER) && isCoordinator()) {
285 updateSleepTimerDuration();
287 } catch (Exception e) {
288 logger.debug("Exception during poll: {}", e.getMessage(), e);
294 public void handleCommand(ChannelUID channelUID, Command command) {
295 if (command == RefreshType.REFRESH) {
296 updateChannel(channelUID.getId());
298 switch (channelUID.getId()) {
305 case NOTIFICATIONSOUND:
306 scheduleNotificationSound(command);
309 stopPlaying(command);
312 setVolumeForGroup(command);
321 setLoudness(command);
324 setSubwoofer(command);
327 setSubwooferGain(command);
330 setSurround(command);
332 case SURROUNDMUSICMODE:
333 setSurroundMusicMode(command);
335 case SURROUNDMUSICLEVEL:
336 setSurroundMusicLevel(command);
338 case SURROUNDTVLEVEL:
339 setSurroundTvLevel(command);
342 setHeightLevel(command);
348 removeMember(command);
351 becomeStandAlonePlayer();
354 publicAddress(LineInType.ANY);
356 case PUBLICANALOGADDRESS:
357 publicAddress(LineInType.ANALOG);
359 case PUBLICDIGITALADDRESS:
360 publicAddress(LineInType.DIGITAL);
365 case TUNEINSTATIONID:
366 playTuneinStation(command);
369 playFavorite(command);
375 snoozeAlarm(command);
378 saveAllPlayerState();
381 restoreAllPlayerState();
390 playPlayList(command);
409 if (command instanceof PlayPauseType) {
410 if (command == PlayPauseType.PLAY) {
411 getCoordinatorHandler().play();
412 } else if (command == PlayPauseType.PAUSE) {
413 getCoordinatorHandler().pause();
416 if (command instanceof NextPreviousType) {
417 if (command == NextPreviousType.NEXT) {
418 getCoordinatorHandler().next();
419 } else if (command == NextPreviousType.PREVIOUS) {
420 getCoordinatorHandler().previous();
423 // Rewind and Fast Forward are currently not implemented by the binding
424 } catch (IllegalStateException e) {
425 logger.debug("Cannot handle control command ({})", e.getMessage());
429 setSleepTimer(command);
438 setNightMode(command);
440 case SPEECHENHANCEMENT:
441 setSpeechEnhancement(command);
449 private void restoreAllPlayerState() {
450 for (Thing aThing : localThingRegistry.getAll()) {
451 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
452 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
453 if (handler != null) {
454 handler.restoreState();
460 private void saveAllPlayerState() {
461 for (Thing aThing : localThingRegistry.getAll()) {
462 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
463 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
464 if (handler != null) {
472 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
473 if (variable == null || value == null || service == null) {
477 if (getThing().getStatus() == ThingStatus.ONLINE) {
478 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
479 new Object[] { variable, value, service, this.getThing().getUID() });
481 String oldValue = this.stateMap.get(variable);
482 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
486 this.stateMap.put(variable, value);
488 // pre-process some variables, eg XML processing
489 if (SERVICE_AV_TRANSPORT.equals(service) && "LastChange".equals(variable)) {
490 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
491 parsedValues.forEach((variable1, value1) -> {
492 // Update the transport state after the update of the media information
493 // to not break the notification mechanism
494 if (!"TransportState".equals(variable1)) {
495 onValueReceived(variable1, value1, service);
497 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
498 // for a compatibility with the result of the action GetMediaInfo
499 if ("AVTransportURI".equals(variable1)) {
500 onValueReceived("CurrentURI", value1, service);
501 } else if ("AVTransportURIMetaData".equals(variable1)) {
502 onValueReceived("CurrentURIMetaData", value1, service);
505 updateMediaInformation();
506 if (parsedValues.get("TransportState") != null) {
507 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
511 if (SERVICE_RENDERING_CONTROL.equals(service) && "LastChange".equals(variable)) {
512 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
513 parsedValues.forEach((variable1, value1) -> {
514 onValueReceived(variable1, value1, service);
518 List<StateOption> options = new ArrayList<>();
520 // update the appropriate channel
522 case "TransportState":
523 updateChannel(STATE);
524 updateChannel(CONTROL);
526 dispatchOnAllGroupMembers(variable, value, service);
528 case "CurrentPlayMode":
529 updateChannel(SHUFFLE);
530 updateChannel(REPEAT);
531 dispatchOnAllGroupMembers(variable, value, service);
533 case "CurrentLEDState":
537 updateState(ZONENAME, new StringType(value));
539 case "CurrentZoneName":
540 updateChannel(ZONENAME);
542 case "ZoneGroupState":
543 updateChannel(COORDINATOR);
544 // Update coordinator after a change is made to the grouping of Sonos players
545 updateGroupCoordinator();
546 updateMediaInformation();
547 // Update state and control channels for the group members with the coordinator values
548 String transportState = getTransportState();
549 if (transportState != null) {
550 dispatchOnAllGroupMembers("TransportState", transportState, SERVICE_AV_TRANSPORT);
552 // Update shuffle and repeat channels for the group members with the coordinator values
553 String playMode = getPlayMode();
554 if (playMode != null) {
555 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, SERVICE_AV_TRANSPORT);
558 case "LocalGroupUUID":
559 updateChannel(ZONEGROUPID);
561 case "GroupCoordinatorIsLocal":
562 updateChannel(LOCALCOORDINATOR);
565 updateChannel(VOLUME);
574 updateChannel(TREBLE);
576 case "LoudnessMaster":
577 updateChannel(LOUDNESS);
581 updateChannel(TREBLE);
582 updateChannel(LOUDNESS);
585 updateChannel(SUBWOOFER);
588 updateChannel(SUBWOOFERGAIN);
590 case "SurroundEnabled":
591 updateChannel(SURROUND);
594 updateChannel(SURROUNDMUSICMODE);
596 case "SurroundLevel":
597 updateChannel(SURROUNDTVLEVEL);
600 updateChannel(CODEC);
602 case "MusicSurroundLevel":
603 updateChannel(SURROUNDMUSICLEVEL);
605 case "HeightChannelLevel":
606 updateChannel(HEIGHTLEVEL);
609 updateChannel(NIGHTMODE);
612 updateChannel(SPEECHENHANCEMENT);
614 case LINEINCONNECTED:
615 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
616 updateChannel(LINEIN);
618 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
619 .contains(getThing().getThingTypeUID())) {
620 updateChannel(ANALOGLINEIN);
623 case TOSLINEINCONNECTED:
624 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
625 updateChannel(LINEIN);
627 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
628 .contains(getThing().getThingTypeUID())) {
629 updateChannel(DIGITALLINEIN);
633 updateChannel(ALARMRUNNING);
634 updateRunningAlarmProperties();
636 case "RunningAlarmProperties":
637 updateChannel(ALARMPROPERTIES);
639 case "CurrentURIFormatted":
640 updateChannel(CURRENTTRACK);
643 updateChannel(CURRENTTITLE);
645 case "CurrentArtist":
646 updateChannel(CURRENTARTIST);
649 updateChannel(CURRENTALBUM);
652 updateChannel(CURRENTTRANSPORTURI);
654 case "CurrentTrackURI":
655 updateChannel(CURRENTTRACKURI);
657 case "CurrentAlbumArtURI":
658 updateChannel(CURRENTALBUMARTURL);
660 case "CurrentSleepTimerGeneration":
661 if ("0".equals(value)) {
662 updateState(SLEEPTIMER, new DecimalType(0));
665 case "SleepTimerGeneration":
666 if ("0".equals(value)) {
667 updateState(SLEEPTIMER, new DecimalType(0));
669 updateSleepTimerDuration();
672 case "RemainingSleepTimerDuration":
673 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
675 case "CurrentTuneInStationId":
676 updateChannel(TUNEINSTATIONID);
678 case "SavedQueuesUpdateID": // service ContentDirectoy
679 for (SonosEntry entry : getPlayLists()) {
680 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
682 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
684 case "FavoritesUpdateID": // service ContentDirectoy
685 for (SonosEntry entry : getFavorites()) {
686 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
688 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
690 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
691 // Due to a bug in the data type definition of this state variable, it is not set.
692 // As a workaround, we check the state variable named ContainerUpdateIDs.
693 case "ContainerUpdateIDs": // service ContentDirectoy
694 if (value.startsWith("R:0,") || stateDescriptionProvider
695 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
696 for (SonosEntry entry : getFavoriteRadios()) {
697 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
699 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
703 updateChannel(BATTERYCHARGING);
704 updateChannel(BATTERYLEVEL);
707 updateChannel(MICROPHONE);
715 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
716 if (isCoordinator()) {
717 for (String member : getOtherZoneGroupMembers()) {
719 ZonePlayerHandler memberHandler = getHandlerByName(member);
720 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
721 memberHandler.onValueReceived(variable, value, service);
723 } catch (IllegalStateException e) {
724 logger.debug("Cannot update channel for group member ({})", e.getMessage());
730 private @Nullable String getAlbumArtUrl() {
732 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
733 if (albumArtURI != null) {
734 if (albumArtURI.startsWith("http")) {
736 } else if (albumArtURI.startsWith("/")) {
738 URL serviceDescrUrl = service.getDescriptorURL(this);
739 if (serviceDescrUrl != null) {
740 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
741 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
743 } catch (MalformedURLException e) {
744 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
751 protected void updateChannel(String channelId) {
752 if (!isLinked(channelId)) {
758 State newState = UnDefType.UNDEF;
762 value = getTransportState();
764 newState = new StringType(value);
768 value = getTransportState();
769 if (STATE_PLAYING.equals(value)) {
770 newState = PlayPauseType.PLAY;
771 } else if (STATE_STOPPED.equals(value)) {
772 newState = PlayPauseType.PAUSE;
773 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
774 newState = PlayPauseType.PAUSE;
778 value = getTransportState();
780 newState = OnOffType.from(STATE_STOPPED.equals(value));
784 if (getPlayMode() != null) {
785 newState = OnOffType.from(isShuffleActive());
789 if (getPlayMode() != null) {
790 newState = new StringType(getRepeatMode());
796 newState = OnOffType.from(value);
800 value = getCurrentZoneName();
802 newState = new StringType(value);
806 value = getZoneGroupID();
808 newState = new StringType(value);
812 newState = new StringType(getCoordinator());
814 case LOCALCOORDINATOR:
815 if (getGroupCoordinatorIsLocal() != null) {
816 newState = OnOffType.from(isGroupCoordinator());
822 newState = new PercentType(value);
827 if (value != null && !isOutputLevelFixed()) {
828 newState = new DecimalType(value);
833 if (value != null && !isOutputLevelFixed()) {
834 newState = new DecimalType(value);
838 value = getLoudness();
839 if (value != null && !isOutputLevelFixed()) {
840 newState = OnOffType.from(value);
846 newState = OnOffType.from(value);
850 value = getSubwooferEnabled();
852 newState = OnOffType.from(value);
856 value = getSubwooferGain();
858 newState = new DecimalType(value);
862 value = getSurroundEnabled();
864 newState = OnOffType.from(value);
867 case SURROUNDMUSICMODE:
868 value = getSurroundMusicMode();
870 newState = new StringType(value);
873 case SURROUNDMUSICLEVEL:
874 value = getSurroundMusicLevel();
876 newState = new DecimalType(value);
879 case SURROUNDTVLEVEL:
880 value = getSurroundTvLevel();
882 newState = new DecimalType(value);
888 newState = new StringType(value);
892 value = getHeightLevel();
894 newState = new DecimalType(value);
898 value = getNightMode();
900 newState = OnOffType.from(value);
903 case SPEECHENHANCEMENT:
904 value = getDialogLevel();
906 newState = OnOffType.from(value);
910 if (getAnalogLineInConnected() != null) {
911 newState = OnOffType.from(isAnalogLineInConnected());
912 } else if (getOpticalLineInConnected() != null) {
913 newState = OnOffType.from(isOpticalLineInConnected());
917 if (getAnalogLineInConnected() != null) {
918 newState = OnOffType.from(isAnalogLineInConnected());
922 if (getOpticalLineInConnected() != null) {
923 newState = OnOffType.from(isOpticalLineInConnected());
927 if (getAlarmRunning() != null) {
928 newState = OnOffType.from(isAlarmRunning());
931 case ALARMPROPERTIES:
932 value = getRunningAlarmProperties();
934 newState = new StringType(value);
938 value = stateMap.get("CurrentURIFormatted");
940 newState = new StringType(value);
944 value = getCurrentTitle();
946 newState = new StringType(value);
950 value = getCurrentArtist();
952 newState = new StringType(value);
956 value = getCurrentAlbum();
958 newState = new StringType(value);
961 case CURRENTALBUMART:
963 updateAlbumArtChannel(false);
965 case CURRENTALBUMARTURL:
966 url = getAlbumArtUrl();
968 newState = new StringType(url);
971 case CURRENTTRANSPORTURI:
972 value = getCurrentURI();
974 newState = new StringType(value);
977 case CURRENTTRACKURI:
978 value = stateMap.get("CurrentTrackURI");
980 newState = new StringType(value);
983 case TUNEINSTATIONID:
984 value = stateMap.get("CurrentTuneInStationId");
986 newState = new StringType(value);
989 case BATTERYCHARGING:
990 value = extractInfoFromMoreInfo("BattChg");
992 newState = OnOffType.from("CHARGING".equalsIgnoreCase(value));
996 value = extractInfoFromMoreInfo("BattPct");
998 newState = new DecimalType(value);
1002 value = getMicEnabled();
1003 if (value != null) {
1004 newState = OnOffType.from(value);
1011 if (newState != null) {
1012 updateState(channelId, newState);
1016 private void updateAlbumArtChannel(boolean allGroup) {
1017 String url = getAlbumArtUrl();
1019 // We download the cover art in a different thread to not delay the other operations
1020 scheduler.submit(() -> {
1021 RawType image = HttpUtil.downloadImage(url, true, 500000);
1022 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
1025 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
1029 private void updateChannel(String channeldD, State state, boolean allGroup) {
1031 for (String member : getZoneGroupMembers()) {
1033 ZonePlayerHandler memberHandler = getHandlerByName(member);
1034 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
1035 && memberHandler.isLinked(channeldD)) {
1036 memberHandler.updateState(channeldD, state);
1038 } catch (IllegalStateException e) {
1039 logger.debug("Cannot update channel for group member ({})", e.getMessage());
1042 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
1043 updateState(channeldD, state);
1048 * CurrentURI will not change, but will trigger change of CurrentURIFormated
1049 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
1051 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
1052 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
1055 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
1056 return oldValue != null ? !oldValue.equals(value) : value != null;
1060 * Similar to the AVTransport eventing, the Queue events its state variables
1061 * as sub values within a synthesized LastChange state variable.
1063 private boolean isQueueEvent(String variable) {
1064 return "LastChange".equals(variable);
1067 private void updateGroupCoordinator() {
1069 coordinatorHandler = getHandlerByName(getCoordinator());
1070 } catch (IllegalStateException e) {
1071 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
1072 coordinatorHandler = null;
1076 private boolean isUpnpDeviceRegistered() {
1077 return service.isRegistered(this);
1080 private void addSubscription() {
1081 synchronized (upnpLock) {
1082 // Set up GENA Subscriptions
1083 if (service.isRegistered(this)) {
1084 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1085 Boolean state = subscriptionState.get(subscription);
1086 if (state == null || !state) {
1087 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
1088 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
1089 subscriptionState.put(subscription, true);
1096 private void removeSubscription() {
1097 synchronized (upnpLock) {
1098 // Set up GENA Subscriptions
1099 if (service.isRegistered(this)) {
1100 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1101 Boolean state = subscriptionState.get(subscription);
1102 if (state != null && state) {
1103 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
1104 service.removeSubscription(this, subscription);
1108 subscriptionState = new HashMap<>();
1113 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
1114 if (service == null) {
1117 synchronized (upnpLock) {
1118 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
1119 subscriptionState.put(service, succeeded);
1123 private Map<String, String> executeAction(String serviceId, String actionId, @Nullable Map<String, String> inputs) {
1124 Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
1125 result.forEach((variable, value) -> {
1126 this.onValueReceived(variable, value, serviceId);
1131 private void updatePlayerState() {
1132 if (!updateZoneInfo()) {
1133 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
1134 logger.debug("Sonos player {} is not available in local network", getUDN());
1135 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1136 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
1137 synchronized (upnpLock) {
1138 subscriptionState = new HashMap<>();
1141 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
1142 logger.debug("Sonos player {} has been found in local network", getUDN());
1143 updateStatus(ThingStatus.ONLINE);
1147 protected void updateCurrentZoneName() {
1148 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_ATTRIBUTES, null);
1151 protected void updateLed() {
1152 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
1155 protected void updateTime() {
1156 executeAction(SERVICE_ALARM_CLOCK, "GetTimeNow", null);
1159 protected void updatePosition() {
1160 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_POSITION_INFO, null);
1163 protected void updateRunningAlarmProperties() {
1164 Map<String, String> result = service.invokeAction(this, SERVICE_AV_TRANSPORT,
1165 ACTION_GET_RUNNING_ALARM_PROPERTIES, null);
1167 String alarmID = result.get("AlarmID");
1168 String loggedStartTime = result.get("LoggedStartTime");
1169 String newStringValue = null;
1170 if (alarmID != null && loggedStartTime != null) {
1171 newStringValue = alarmID + " - " + loggedStartTime;
1173 newStringValue = "No running alarm";
1175 result.put("RunningAlarmProperties", newStringValue);
1177 result.forEach((variable, value) -> {
1178 this.onValueReceived(variable, value, SERVICE_AV_TRANSPORT);
1182 protected boolean updateZoneInfo() {
1183 Map<String, String> result = executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_INFO, null);
1185 Map<String, String> properties = editProperties();
1186 String value = stateMap.get("HardwareVersion");
1187 if (value != null && !value.isEmpty()) {
1188 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1190 value = stateMap.get("DisplaySoftwareVersion");
1191 if (value != null && !value.isEmpty()) {
1192 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1194 value = stateMap.get("SerialNumber");
1195 if (value != null && !value.isEmpty()) {
1196 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1198 value = stateMap.get("MACAddress");
1199 if (value != null && !value.isEmpty()) {
1200 properties.put(MAC_ADDRESS, value);
1202 value = stateMap.get("IPAddress");
1203 if (value != null && !value.isEmpty()) {
1204 properties.put(IP_ADDRESS, value);
1206 updateProperties(properties);
1208 return !result.isEmpty();
1211 public String getCoordinator() {
1212 for (SonosZoneGroup zg : getZoneGroups()) {
1213 if (zg.getMembers().contains(getUDN())) {
1214 return zg.getCoordinator();
1220 public boolean isCoordinator() {
1221 return getUDN().equals(getCoordinator());
1224 protected void updateMediaInformation() {
1225 String currentURI = getCurrentURI();
1226 SonosMetaData currentTrack = getTrackMetadata();
1227 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1229 String stationID = null;
1230 SonosMediaInformation mediaInfo = new SonosMediaInformation();
1232 // if currentURI == null, we do nothing
1233 if (currentURI != null) {
1234 if (currentURI.isEmpty()) {
1236 mediaInfo = new SonosMediaInformation(true);
1239 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1240 // The Sonos is a slave member of a group
1241 // The media information will be updated by the coordinator
1242 // Notification of group change occurs later, so we just check the URI
1244 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1245 // Radio stream (tune-in)
1246 stationID = extractStationId(currentURI);
1247 mediaInfo = SonosMediaInformation.parseTuneInMediaInfo(buildOpmlUrl(stationID),
1248 currentUriMetaData != null ? currentUriMetaData.getTitle() : null, currentTrack);
1251 else if (isPlayingRadioApp(currentURI)) {
1252 mediaInfo = SonosMediaInformation.parseRadioAppMediaInfo(
1253 currentUriMetaData != null ? currentUriMetaData.getTitle() : null, currentTrack);
1256 else if (isPlayingLineIn(currentURI)) {
1257 mediaInfo = SonosMediaInformation.parseTrackTitle(currentTrack);
1260 else if (isPlayingRadio(currentURI)
1261 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1262 mediaInfo = SonosMediaInformation.parseTrack(currentTrack);
1266 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1267 ? currentTrack.getAlbumArtUri()
1270 ZonePlayerHandler handlerForImageUpdate = null;
1271 for (String member : getZoneGroupMembers()) {
1273 ZonePlayerHandler memberHandler = getHandlerByName(member);
1274 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1275 if (memberHandler.isLinked(CURRENTALBUMART)
1276 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1277 handlerForImageUpdate = memberHandler;
1279 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1280 SERVICE_AV_TRANSPORT);
1281 if (mediaInfo.needsUpdate()) {
1282 String artist = mediaInfo.getArtist();
1283 String album = mediaInfo.getAlbum();
1284 String title = mediaInfo.getTitle();
1285 String combinedInfo = mediaInfo.getCombinedInfo();
1286 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "",
1287 SERVICE_AV_TRANSPORT);
1288 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "",
1289 SERVICE_AV_TRANSPORT);
1290 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "",
1291 SERVICE_AV_TRANSPORT);
1292 memberHandler.onValueReceived("CurrentURIFormatted", (combinedInfo != null) ? combinedInfo : "",
1293 SERVICE_AV_TRANSPORT);
1294 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, SERVICE_AV_TRANSPORT);
1297 } catch (IllegalStateException e) {
1298 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1301 if (mediaInfo.needsUpdate() && handlerForImageUpdate != null) {
1302 handlerForImageUpdate.updateAlbumArtChannel(true);
1306 private @Nullable String buildOpmlUrl(@Nullable String stationId) {
1307 String url = opmlUrl;
1308 if (url != null && stationId != null && !stationId.isEmpty()) {
1309 String mac = getMACAddress();
1310 if (mac != null && !mac.isEmpty()) {
1311 url = url.replace("%id", stationId);
1312 url = url.replace("%serial", mac);
1319 private @Nullable String extractStationId(String uri) {
1320 String stationID = null;
1321 if (isPlayingStream(uri)) {
1322 stationID = substringBetween(uri, ":s", "?sid");
1323 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1324 stationID = substringBetween(uri, "sid=s", "&");
1329 private @Nullable String substringBetween(String str, String open, String close) {
1330 String result = null;
1331 int idx1 = str.indexOf(open);
1333 idx1 += open.length();
1334 int idx2 = str.indexOf(close, idx1);
1336 result = str.substring(idx1, idx2);
1342 public @Nullable String getGroupCoordinatorIsLocal() {
1343 return stateMap.get("GroupCoordinatorIsLocal");
1346 public boolean isGroupCoordinator() {
1347 return "true".equals(getGroupCoordinatorIsLocal());
1351 public String getUDN() {
1352 String udn = configuration.udn;
1353 return udn != null && !udn.isEmpty() ? udn : "undefined";
1356 public @Nullable String getCurrentURI() {
1357 return stateMap.get("CurrentURI");
1360 public @Nullable String getCurrentURIMetadataAsString() {
1361 return stateMap.get("CurrentURIMetaData");
1364 public @Nullable SonosMetaData getCurrentURIMetadata() {
1365 String metaData = getCurrentURIMetadataAsString();
1366 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1369 public @Nullable SonosMetaData getTrackMetadata() {
1370 String metaData = stateMap.get("CurrentTrackMetaData");
1371 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1374 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1375 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1376 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1379 public @Nullable String getMACAddress() {
1380 String mac = stateMap.get("MACAddress");
1381 if (mac == null || mac.isEmpty()) {
1384 return stateMap.get("MACAddress");
1387 public @Nullable String getRefreshedPosition() {
1389 return stateMap.get("RelTime");
1392 public long getRefreshedCurrenTrackNr() {
1394 String value = stateMap.get("Track");
1395 if (value != null) {
1396 return Long.valueOf(value);
1402 public @Nullable String getVolume() {
1403 return stateMap.get("VolumeMaster");
1406 public boolean isOutputLevelFixed() {
1407 return "1".equals(stateMap.get("OutputFixed"));
1410 public @Nullable String getBass() {
1411 return stateMap.get("Bass");
1414 public @Nullable String getTreble() {
1415 return stateMap.get("Treble");
1418 public @Nullable String getLoudness() {
1419 return stateMap.get("LoudnessMaster");
1422 public @Nullable String getSurroundEnabled() {
1423 return stateMap.get("SurroundEnabled");
1426 public @Nullable String getSurroundMusicMode() {
1427 return stateMap.get("SurroundMode");
1430 public @Nullable String getSurroundTvLevel() {
1431 return stateMap.get("SurroundLevel");
1434 public @Nullable String getSurroundMusicLevel() {
1435 return stateMap.get("MusicSurroundLevel");
1438 public @Nullable String getCodec() {
1439 String codec = stateMap.get("HTAudioIn");
1440 if (codec != null) {
1455 codec = "dolbyAtmos";
1473 codec = "Unknown - " + codec;
1479 public @Nullable String getSubwooferEnabled() {
1480 return stateMap.get("SubEnabled");
1483 public @Nullable String getSubwooferGain() {
1484 return stateMap.get("SubGain");
1487 public @Nullable String getHeightLevel() {
1488 return stateMap.get("HeightChannelLevel");
1491 public @Nullable String getTransportState() {
1492 return stateMap.get("TransportState");
1495 public @Nullable String getCurrentTitle() {
1496 return stateMap.get("CurrentTitle");
1499 public @Nullable String getCurrentArtist() {
1500 return stateMap.get("CurrentArtist");
1503 public @Nullable String getCurrentAlbum() {
1504 return stateMap.get("CurrentAlbum");
1507 public List<SonosEntry> getArtists(String filter) {
1508 return getEntries("A:", filter);
1511 public List<SonosEntry> getArtists() {
1512 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1515 public List<SonosEntry> getAlbums(String filter) {
1516 return getEntries("A:ALBUM", filter);
1519 public List<SonosEntry> getAlbums() {
1520 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1523 public List<SonosEntry> getTracks(String filter) {
1524 return getEntries("A:TRACKS", filter);
1527 public List<SonosEntry> getTracks() {
1528 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1531 public List<SonosEntry> getQueue(String filter) {
1532 return getEntries("Q:0", filter);
1535 public List<SonosEntry> getQueue() {
1536 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1539 public long getQueueSize() {
1540 return getNbEntries("Q:0");
1543 public List<SonosEntry> getPlayLists(String filter) {
1544 return getEntries("SQ:", filter);
1547 public List<SonosEntry> getPlayLists() {
1548 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1551 public List<SonosEntry> getFavoriteRadios(String filter) {
1552 return getEntries("R:0/0", filter);
1555 public List<SonosEntry> getFavoriteRadios() {
1556 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1560 * Searches for entries in the 'favorites' list on a sonos account
1564 public List<SonosEntry> getFavorites() {
1565 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1568 protected List<SonosEntry> getEntries(String type, String filter) {
1571 Map<String, String> inputs = new HashMap<>();
1572 inputs.put("ObjectID", type);
1573 inputs.put("BrowseFlag", "BrowseDirectChildren");
1574 inputs.put("Filter", filter);
1575 inputs.put("StartingIndex", Long.toString(startAt));
1576 inputs.put("RequestedCount", Integer.toString(200));
1577 inputs.put("SortCriteria", "");
1579 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1581 String initialResult = result.get("Result");
1582 if (initialResult == null) {
1583 return Collections.emptyList();
1586 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1587 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1589 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1590 startAt = startAt + initialNumberReturned;
1592 while (startAt < totalMatches) {
1593 inputs.put("StartingIndex", Long.toString(startAt));
1594 result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1596 // Execute this action synchronously
1597 String nextResult = result.get("Result");
1598 if (nextResult == null) {
1602 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1604 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1606 startAt = startAt + numberReturned;
1612 protected long getNbEntries(String type) {
1613 Map<String, String> inputs = new HashMap<>();
1614 inputs.put("ObjectID", type);
1615 inputs.put("BrowseFlag", "BrowseDirectChildren");
1616 inputs.put("Filter", "dc:title");
1617 inputs.put("StartingIndex", "0");
1618 inputs.put("RequestedCount", "1");
1619 inputs.put("SortCriteria", "");
1621 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1623 return getResultEntry(result, "TotalMatches", type, "dc:title");
1627 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1629 * @param resultInput - the map to be examined for the requestedKey
1630 * @param requestedKey - the key to be sought in the resultInput map
1631 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1632 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1634 * @return 0 as long or the value corresponding to the requiredKey if found
1636 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1637 String entriesFilter) {
1640 if (resultInput.isEmpty()) {
1645 String resultString = resultInput.get(requestedKey);
1646 if (resultString == null) {
1647 throw new NumberFormatException("Requested key is null.");
1649 result = Long.valueOf(resultString);
1650 } catch (NumberFormatException ex) {
1651 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1652 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1659 * Save the state (track, position etc) of the Sonos Zone player.
1661 * @return true if no error occurred.
1663 protected void saveState() {
1664 synchronized (stateLock) {
1665 savedState = new SonosZonePlayerState();
1666 String currentURI = getCurrentURI();
1668 savedState.transportState = getTransportState();
1669 savedState.volume = getVolume();
1671 if (currentURI != null) {
1672 if (isPlayingStreamOrRadio(currentURI)) {
1673 // we are streaming music, like tune-in radio or Google Play Music radio
1674 SonosMetaData track = getTrackMetadata();
1675 SonosMetaData current = getCurrentURIMetadata();
1676 if (track != null && current != null) {
1677 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1678 current.getUpnpClass(), currentURI);
1680 } else if (currentURI.contains(GROUP_URI)) {
1681 // we are a slave to some coordinator
1682 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1683 } else if (isPlayingLineIn(currentURI)) {
1684 // we are streaming from the Line In connection
1685 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1686 } else if (isPlayingQueue(currentURI)) {
1687 // we are playing something that sits in the queue
1688 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1689 if (queued != null) {
1690 savedState.track = getRefreshedCurrenTrackNr();
1692 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1693 // we are playing a real 'saved' playlist
1694 List<SonosEntry> playLists = getPlayLists();
1695 for (SonosEntry someList : playLists) {
1696 if (someList.getTitle().equals(queued.getTitle())) {
1697 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1698 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1703 } else if (queued.getUpnpClass().contains("object.container")) {
1704 // we are playing some other sort of
1705 // 'container' - we will save that to a
1706 // playlist for our convenience
1707 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1709 // save the playlist
1710 String existingList = "";
1711 List<SonosEntry> playLists = getPlayLists();
1712 for (SonosEntry someList : playLists) {
1713 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1714 existingList = someList.getId();
1719 saveQueue(TITLE_PREFIX + getUDN(), existingList);
1721 // get all the playlists and a ref to our
1723 playLists = getPlayLists();
1724 for (SonosEntry someList : playLists) {
1725 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1726 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1727 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1734 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1738 savedState.relTime = getRefreshedPosition();
1740 savedState.entry = null;
1746 * Restore the state (track, position etc) of the Sonos Zone player.
1748 * @return true if no error occurred.
1750 protected void restoreState() {
1751 synchronized (stateLock) {
1752 SonosZonePlayerState state = savedState;
1753 if (state != null) {
1754 // put settings back
1755 String volume = state.volume;
1756 if (volume != null) {
1757 setVolume(DecimalType.valueOf(volume));
1760 if (isCoordinator()) {
1761 SonosEntry entry = state.entry;
1762 if (entry != null) {
1763 // check if we have a playlist to deal with
1764 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1765 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1766 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1767 setCurrentURI(entry);
1768 setPositionTrack(state.track);
1770 setCurrentURI(entry);
1771 setPosition(state.relTime);
1775 String transportState = state.transportState;
1776 if (STATE_PLAYING.equals(transportState)) {
1778 } else if (STATE_STOPPED.equals(transportState)) {
1780 } else if (STATE_PAUSED_PLAYBACK.equals(transportState)) {
1788 public void saveQueue(String name, String queueID) {
1789 executeAction(SERVICE_AV_TRANSPORT, ACTION_SAVE_QUEUE, Map.of("Title", name, "ObjectID", queueID));
1792 public void setVolume(Command command) {
1793 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1794 || command instanceof PercentType) {
1795 String newValue = null;
1796 String currentVolume = getVolume();
1797 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1798 int i = Integer.valueOf(currentVolume);
1799 newValue = String.valueOf(Math.min(100, i + 1));
1800 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1801 int i = Integer.valueOf(currentVolume);
1802 newValue = String.valueOf(Math.max(0, i - 1));
1803 } else if (command == OnOffType.ON) {
1805 } else if (command == OnOffType.OFF) {
1807 } else if (command instanceof DecimalType) {
1808 newValue = String.valueOf(((DecimalType) command).intValue());
1812 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_VOLUME,
1813 Map.of("Channel", "Master", "DesiredVolume", newValue));
1818 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1819 * AdHoc groups handles the volume specifically for each player.
1820 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1822 public void setVolumeForGroup(Command command) {
1823 if (isAdHocGroup() || isStandalonePlayer()) {
1827 getCoordinatorHandler().setVolume(command);
1828 } catch (IllegalStateException e) {
1829 logger.debug("Cannot set group volume ({})", e.getMessage());
1834 public void setBass(Command command) {
1835 if (!isOutputLevelFixed()) {
1836 String newValue = getNewNumericValue(command, getBass(), MIN_BASS, MAX_BASS);
1837 if (newValue != null) {
1838 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_BASS,
1839 Map.of("InstanceID", "0", "DesiredBass", newValue));
1844 public void setTreble(Command command) {
1845 if (!isOutputLevelFixed()) {
1846 String newValue = getNewNumericValue(command, getTreble(), MIN_TREBLE, MAX_TREBLE);
1847 if (newValue != null) {
1848 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_TREBLE,
1849 Map.of("InstanceID", "0", "DesiredTreble", newValue));
1854 private @Nullable String getNewNumericValue(Command command, @Nullable String currentValue, int minValue,
1856 String newValue = null;
1857 if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1858 if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1859 int i = Integer.valueOf(currentValue);
1860 newValue = String.valueOf(Math.min(maxValue, i + 1));
1861 } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1862 int i = Integer.valueOf(currentValue);
1863 newValue = String.valueOf(Math.max(minValue, i - 1));
1864 } else if (command instanceof DecimalType) {
1865 newValue = String.valueOf(((DecimalType) command).intValue());
1871 public void setLoudness(Command command) {
1872 if (!isOutputLevelFixed() && (command instanceof OnOffType || command instanceof OpenClosedType
1873 || command instanceof UpDownType)) {
1874 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1875 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
1876 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_LOUDNESS,
1877 Map.of("InstanceID", "0", "Channel", "Master", "DesiredLoudness", value));
1882 * Checks if the player receiving the command is part of a group that
1883 * consists of randomly added players or contains bonded players
1887 private boolean isAdHocGroup() {
1888 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1889 if (currentZoneGroup != null) {
1890 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1892 for (String zoneName : zoneGroupMemberNames) {
1893 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1894 // At least one "ZoneName" differs so we have an AdHoc group
1903 * Checks if the player receiving the command is a standalone player
1907 private boolean isStandalonePlayer() {
1908 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1909 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1912 private Collection<SonosZoneGroup> getZoneGroups() {
1913 String zoneGroupState = stateMap.get("ZoneGroupState");
1914 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1918 * Returns the current zone group
1919 * (of which the player receiving the command is part)
1921 * @return {@link SonosZoneGroup}
1923 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1924 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1925 if (zoneGroup.getMembers().contains(getUDN())) {
1929 logger.debug("Could not fetch Sonos group state information");
1934 * Sets the volume level for a notification sound
1936 * @param notificationSoundVolume
1938 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1939 if (notificationSoundVolume != null) {
1940 setVolumeForGroup(notificationSoundVolume);
1945 * Gets the volume level for a notification sound
1947 public @Nullable PercentType getNotificationSoundVolume() {
1948 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1949 if (notificationSoundVolume == null) {
1950 // if no value is set we use the current volume instead
1951 String volume = getVolume();
1952 return volume != null ? new PercentType(volume) : null;
1954 return new PercentType(notificationSoundVolume);
1957 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1958 Map<String, String> inputs = new HashMap<>();
1961 inputs.put("InstanceID", "0");
1962 inputs.put("EnqueuedURI", URI);
1963 inputs.put("EnqueuedURIMetaData", meta);
1964 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1965 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1966 } catch (NumberFormatException ex) {
1967 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1970 executeAction(SERVICE_AV_TRANSPORT, ACTION_ADD_URI_TO_QUEUE, inputs);
1973 public void setCurrentURI(SonosEntry newEntry) {
1974 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1977 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1978 if (URI != null && URIMetaData != null) {
1979 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1980 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_AV_TRANSPORT_URI,
1981 Map.of("InstanceID", "0", "CurrentURI", URI, "CurrentURIMetaData", URIMetaData));
1985 public void setPosition(@Nullable String relTime) {
1986 seek("REL_TIME", relTime);
1989 public void setPositionTrack(long tracknr) {
1990 seek("TRACK_NR", Long.toString(tracknr));
1993 public void setPositionTrack(String tracknr) {
1994 seek("TRACK_NR", tracknr);
1997 protected void seek(String unit, @Nullable String target) {
1998 if (target != null) {
1999 executeAction(SERVICE_AV_TRANSPORT, ACTION_SEEK, Map.of("InstanceID", "0", "Unit", unit, "Target", target));
2003 public void play() {
2004 executeAction(SERVICE_AV_TRANSPORT, ACTION_PLAY, Map.of("Speed", "1"));
2007 public void stop() {
2008 executeAction(SERVICE_AV_TRANSPORT, ACTION_STOP, null);
2011 public void pause() {
2012 executeAction(SERVICE_AV_TRANSPORT, ACTION_PAUSE, null);
2015 public void setShuffle(Command command) {
2016 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2018 ZonePlayerHandler coordinator = getCoordinatorHandler();
2020 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2021 || command.equals(OpenClosedType.OPEN)) {
2022 switch (coordinator.getRepeatMode()) {
2024 coordinator.updatePlayMode("SHUFFLE");
2027 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
2030 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
2033 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2034 || command.equals(OpenClosedType.CLOSED)) {
2035 switch (coordinator.getRepeatMode()) {
2037 coordinator.updatePlayMode("REPEAT_ALL");
2040 coordinator.updatePlayMode("REPEAT_ONE");
2043 coordinator.updatePlayMode("NORMAL");
2047 } catch (IllegalStateException e) {
2048 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
2053 public void setRepeat(Command command) {
2054 if (command instanceof StringType) {
2056 ZonePlayerHandler coordinator = getCoordinatorHandler();
2058 switch (command.toString()) {
2060 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
2063 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
2066 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
2069 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
2070 command.toString());
2073 } catch (IllegalStateException e) {
2074 logger.debug("Cannot handle repeat command ({})", e.getMessage());
2079 public void setSubwoofer(Command command) {
2080 setEqualizerBooleanSetting(command, "SubEnable");
2083 public void setSubwooferGain(Command command) {
2084 setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
2087 public void setSurround(Command command) {
2088 setEqualizerBooleanSetting(command, "SurroundEnable");
2091 public void setSurroundMusicMode(Command command) {
2092 if (command instanceof StringType) {
2093 setEQ("SurroundMode", command.toString());
2097 public void setSurroundMusicLevel(Command command) {
2098 setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
2099 MAX_SURROUND_LEVEL);
2102 public void setSurroundTvLevel(Command command) {
2103 setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
2104 MAX_SURROUND_LEVEL);
2107 public void setHeightLevel(Command command) {
2108 setEqualizerNumericSetting(command, "HeightChannelLevel", getHeightLevel(), MIN_HEIGHT_LEVEL, MAX_HEIGHT_LEVEL);
2111 public void setNightMode(Command command) {
2112 setEqualizerBooleanSetting(command, "NightMode");
2115 public void setSpeechEnhancement(Command command) {
2116 setEqualizerBooleanSetting(command, "DialogLevel");
2119 private void setEqualizerBooleanSetting(Command command, String eqType) {
2120 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2121 setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2122 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
2126 private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
2128 String newValue = getNewNumericValue(command, currentValue, minValue, maxValue);
2129 if (newValue != null) {
2130 setEQ(eqType, newValue);
2134 private void setEQ(String eqType, String value) {
2136 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_EQ,
2137 Map.of("InstanceID", "0", "EQType", eqType, "DesiredValue", value));
2138 } catch (IllegalStateException e) {
2139 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2143 public @Nullable String getNightMode() {
2144 return stateMap.get("NightMode");
2147 public @Nullable String getDialogLevel() {
2148 return stateMap.get("DialogLevel");
2151 public @Nullable String getPlayMode() {
2152 return stateMap.get("CurrentPlayMode");
2155 public Boolean isShuffleActive() {
2156 String playMode = getPlayMode();
2157 return (playMode != null && playMode.startsWith("SHUFFLE"));
2160 public String getRepeatMode() {
2161 String mode = "OFF";
2162 String playMode = getPlayMode();
2163 if (playMode != null) {
2170 case "SHUFFLE_REPEAT_ONE":
2174 case "SHUFFLE_NOREPEAT":
2183 public @Nullable String getMicEnabled() {
2184 return stateMap.get("MicEnabled");
2187 protected void updatePlayMode(String playMode) {
2188 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_PLAY_MODE, Map.of("InstanceID", "0", "NewPlayMode", playMode));
2192 * Clear all scheduled music from the current queue.
2195 public void removeAllTracksFromQueue() {
2196 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE, Map.of("InstanceID", "0"));
2200 * Play music from the line-in of the given Player referenced by the given UDN or name
2202 * @param udn or name
2204 public void playLineIn(Command command) {
2205 if (command instanceof StringType) {
2207 LineInType lineInType = LineInType.ANY;
2208 String remotePlayerName = command.toString();
2209 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2210 lineInType = LineInType.ANALOG;
2211 remotePlayerName = remotePlayerName.substring(7);
2212 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2213 lineInType = LineInType.DIGITAL;
2214 remotePlayerName = remotePlayerName.substring(8);
2216 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2217 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2219 // check if player has a line-in connected
2220 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2221 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2222 // stop whatever is currently playing
2223 coordinatorHandler.stop();
2226 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2227 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2229 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2232 // take the system off mute
2233 coordinatorHandler.setMute(OnOffType.OFF);
2236 coordinatorHandler.play();
2238 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2240 } catch (IllegalStateException e) {
2241 logger.debug("Cannot play line-in ({})", e.getMessage());
2246 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2247 ZonePlayerHandler handler = coordinatorHandler;
2248 if (handler != null) {
2252 handler = getHandlerByName(getCoordinator());
2253 coordinatorHandler = handler;
2255 } catch (IllegalStateException e) {
2256 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2261 * Returns a list of all zone group members this particular player is member of
2262 * Or empty list if the players is not assigned to any group
2264 * @return a list of Strings containing the UDNs of other group members
2266 protected List<String> getZoneGroupMembers() {
2267 List<String> result = new ArrayList<>();
2269 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2270 if (!zoneGroups.isEmpty()) {
2271 for (SonosZoneGroup zg : zoneGroups) {
2272 if (zg.getMembers().contains(getUDN())) {
2273 result.addAll(zg.getMembers());
2278 // If the group topology was not yet received, return at least the current Sonos zone
2279 result.add(getUDN());
2285 * Returns a list of other zone group members this particular player is member of
2286 * Or empty list if the players is not assigned to any group
2288 * @return a list of Strings containing the UDNs of other group members
2290 protected List<String> getOtherZoneGroupMembers() {
2291 List<String> zoneGroupMembers = getZoneGroupMembers();
2292 zoneGroupMembers.remove(getUDN());
2293 return zoneGroupMembers;
2296 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2297 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2298 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2299 if (thing != null) {
2300 ThingHandler handler = thing.getHandler();
2301 if (handler instanceof ZonePlayerHandler) {
2302 return (ZonePlayerHandler) handler;
2306 for (Thing aThing : localThingRegistry.getAll()) {
2307 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2308 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2309 ThingHandler handler = aThing.getHandler();
2310 if (handler instanceof ZonePlayerHandler) {
2311 return (ZonePlayerHandler) handler;
2315 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2318 public void setMute(Command command) {
2319 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2320 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2321 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
2322 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_MUTE,
2323 Map.of("Channel", "Master", "DesiredMute", value));
2327 public List<SonosAlarm> getCurrentAlarmList() {
2328 Map<String, String> result = executeAction(SERVICE_ALARM_CLOCK, "ListAlarms", null);
2329 String alarmList = result.get("CurrentAlarmList");
2330 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2333 public void updateAlarm(SonosAlarm alarm) {
2334 Map<String, String> inputs = new HashMap<>();
2337 inputs.put("ID", Integer.toString(alarm.getId()));
2338 inputs.put("StartLocalTime", alarm.getStartTime());
2339 inputs.put("Duration", alarm.getDuration());
2340 inputs.put("Recurrence", alarm.getRecurrence());
2341 inputs.put("RoomUUID", alarm.getRoomUUID());
2342 inputs.put("ProgramURI", alarm.getProgramURI());
2343 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2344 inputs.put("PlayMode", alarm.getPlayMode());
2345 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2346 if (alarm.getIncludeLinkedZones()) {
2347 inputs.put("IncludeLinkedZones", "1");
2349 inputs.put("IncludeLinkedZones", "0");
2352 if (alarm.getEnabled()) {
2353 inputs.put("Enabled", "1");
2355 inputs.put("Enabled", "0");
2357 } catch (NumberFormatException ex) {
2358 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2361 executeAction(SERVICE_ALARM_CLOCK, "UpdateAlarm", inputs);
2364 public void setAlarm(Command command) {
2365 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2366 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2368 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2369 || command.equals(OpenClosedType.CLOSED)) {
2375 public void setAlarm(boolean alarmSwitch) {
2376 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2378 // find the nearest alarm - take the current time from the Sonos system,
2379 // not the system where we are running
2380 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2381 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2383 String currentLocalTime = getTime();
2384 Date currentDateTime = null;
2386 currentDateTime = fmt.parse(currentLocalTime);
2387 } catch (ParseException e) {
2388 logger.debug("An exception occurred while formatting a date", e);
2391 if (currentDateTime != null) {
2392 Calendar currentDateTimeCalendar = Calendar.getInstance();
2393 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2394 currentDateTimeCalendar.setTime(currentDateTime);
2395 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2396 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2398 SonosAlarm firstAlarm = null;
2400 for (SonosAlarm anAlarm : sonosAlarms) {
2401 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2402 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2405 durationDate = durationFormat.parse(anAlarm.getDuration());
2406 } catch (ParseException e) {
2407 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2411 long duration = durationDate.getTime();
2413 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2414 shortestDuration = duration;
2415 firstAlarm = anAlarm;
2420 if (firstAlarm != null) {
2422 firstAlarm.setEnabled(true);
2424 firstAlarm.setEnabled(false);
2427 updateAlarm(firstAlarm);
2432 public @Nullable String getTime() {
2434 return stateMap.get("CurrentLocalTime");
2437 public @Nullable String getAlarmRunning() {
2438 return stateMap.get("AlarmRunning");
2441 public boolean isAlarmRunning() {
2442 return "1".equals(getAlarmRunning());
2445 public void snoozeAlarm(Command command) {
2446 if (isAlarmRunning() && command instanceof DecimalType) {
2447 int minutes = ((DecimalType) command).intValue();
2449 Map<String, String> inputs = new HashMap<>();
2451 Calendar snoozePeriod = Calendar.getInstance();
2452 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2453 snoozePeriod.setTimeInMillis(0);
2454 snoozePeriod.add(Calendar.MINUTE, minutes);
2455 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2456 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2459 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2460 } catch (NumberFormatException ex) {
2461 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2464 executeAction(SERVICE_AV_TRANSPORT, ACTION_SNOOZE_ALARM, inputs);
2466 logger.debug("There is no alarm running on {}", getUDN());
2470 public @Nullable String getAnalogLineInConnected() {
2471 return stateMap.get(LINEINCONNECTED);
2474 public boolean isAnalogLineInConnected() {
2475 return "true".equals(getAnalogLineInConnected());
2478 public @Nullable String getOpticalLineInConnected() {
2479 return stateMap.get(TOSLINEINCONNECTED);
2482 public boolean isOpticalLineInConnected() {
2483 return "true".equals(getOpticalLineInConnected());
2486 public void becomeStandAlonePlayer() {
2487 executeAction(SERVICE_AV_TRANSPORT, ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP, null);
2490 public void addMember(Command command) {
2491 if (command instanceof StringType) {
2492 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2494 getHandlerByName(command.toString()).setCurrentURI(entry);
2495 } catch (IllegalStateException e) {
2496 logger.debug("Cannot add group member ({})", e.getMessage());
2501 @SuppressWarnings("PMD.CompareObjectsWithEquals")
2502 public boolean publicAddress(LineInType lineInType) {
2503 // check if sourcePlayer has a line-in connected
2504 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2505 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2506 // first remove this player from its own group if any
2507 becomeStandAlonePlayer();
2509 // add all other players to this new group
2510 for (SonosZoneGroup group : getZoneGroups()) {
2511 for (String player : group.getMembers()) {
2513 ZonePlayerHandler somePlayer = getHandlerByName(player);
2514 if (somePlayer != this) {
2515 somePlayer.becomeStandAlonePlayer();
2517 addMember(StringType.valueOf(somePlayer.getUDN()));
2519 } catch (IllegalStateException e) {
2520 logger.debug("Cannot add to group ({})", e.getMessage());
2526 ZonePlayerHandler coordinator = getCoordinatorHandler();
2527 // set the URI of the group to the line-in
2528 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2529 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2530 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2532 coordinator.setCurrentURI(entry);
2536 } catch (IllegalStateException e) {
2537 logger.debug("Cannot handle command ({})", e.getMessage());
2541 logger.debug("Line-in of {} is not connected", getUDN());
2547 * Play a given url to music in one of the music libraries.
2550 * in the format of //host/folder/filename.mp3
2552 public void playURI(Command command) {
2553 if (command instanceof StringType) {
2555 String url = command.toString();
2557 ZonePlayerHandler coordinator = getCoordinatorHandler();
2559 // stop whatever is currently playing
2561 coordinator.waitForNotTransportState(STATE_PLAYING);
2563 // clear any tracks which are pending in the queue
2564 coordinator.removeAllTracksFromQueue();
2566 // add the new track we want to play to the queue
2567 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2568 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2569 // default to file based url
2570 url = FILE_URI + url;
2572 coordinator.addURIToQueue(url, "", 0, true);
2574 // set the current playlist to our new queue
2575 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2577 // take the system off mute
2578 coordinator.setMute(OnOffType.OFF);
2582 } catch (IllegalStateException e) {
2583 logger.debug("Cannot play URI ({})", e.getMessage());
2584 } catch (InterruptedException e) {
2585 logger.debug("Play URI interrupted ({})", e.getMessage());
2586 Thread.currentThread().interrupt();
2591 private void scheduleNotificationSound(final Command command) {
2592 scheduler.submit(() -> {
2593 synchronized (notificationLock) {
2594 playNotificationSoundURI(command);
2600 * Play a given notification sound
2602 * @param url in the format of //host/folder/filename.mp3
2604 public void playNotificationSoundURI(Command notificationURL) {
2605 if (notificationURL instanceof StringType) {
2607 ZonePlayerHandler coordinator = getCoordinatorHandler();
2609 String currentURI = coordinator.getCurrentURI();
2610 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2611 coordinator.getCurrentURIMetadataAsString());
2613 if (isPlayingStreamOrRadio(currentURI)) {
2614 handleNotifForRadioStream(currentURI, notificationURL, coordinator);
2615 } else if (isPlayingLineIn(currentURI)) {
2616 handleNotifForLineIn(currentURI, notificationURL, coordinator);
2617 } else if (isPlayingVirtualLineIn(currentURI)) {
2618 handleNotifForVirtualLineIn(currentURI, notificationURL, coordinator);
2619 } else if (isPlayingQueue(currentURI)) {
2620 handleNotifForSharedQueue(currentURI, notificationURL, coordinator);
2621 } else if (isPlaylistEmpty(coordinator)) {
2622 handleNotifForEmptyQueue(notificationURL, coordinator);
2624 logger.debug("Notification feature not yet implemented while the current media is being played");
2626 synchronized (notificationLock) {
2627 notificationLock.notify();
2629 } catch (IllegalStateException e) {
2630 logger.debug("Cannot play notification sound ({})", e.getMessage());
2631 } catch (InterruptedException e) {
2632 logger.debug("Play notification sound interrupted ({})", e.getMessage());
2633 Thread.currentThread().interrupt();
2638 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2639 return coordinator.getQueueSize() == 0;
2642 private boolean isPlayingQueue(@Nullable String currentURI) {
2643 return currentURI != null && currentURI.contains(QUEUE_URI);
2646 private boolean isPlayingStream(@Nullable String currentURI) {
2647 return currentURI != null && currentURI.contains(STREAM_URI);
2650 private boolean isPlayingRadio(@Nullable String currentURI) {
2651 // Google Play Music radio or Apple Music radio
2652 return currentURI != null && currentURI.contains(RADIO_URI);
2655 private boolean isPlayingRadioApp(@Nullable String currentURI) {
2656 // RadioApp music service
2657 return currentURI != null && currentURI.contains(RADIOAPP_URI);
2660 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2661 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2664 private boolean isPlayingStreamOrRadio(@Nullable String currentURI) {
2665 return isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2666 || isPlayingRadio(currentURI) || isPlayingRadioApp(currentURI);
2669 private boolean isPlayingLineIn(@Nullable String currentURI) {
2670 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2673 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2674 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2677 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2678 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2681 private boolean isPlayingVirtualLineIn(@Nullable String currentURI) {
2682 return currentURI != null && currentURI.startsWith(VIRTUAL_LINE_IN_URI);
2686 * Does a chain of predefined actions when a Notification sound is played by
2687 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2688 * radio streaming is currently loaded
2690 * @param currentStreamURI - the currently loaded stream's URI
2691 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2692 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2693 * @throws InterruptedException
2695 private void handleNotifForRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2696 ZonePlayerHandler coordinator) throws InterruptedException {
2697 String nextAction = coordinator.getTransportState();
2698 SonosMetaData track = coordinator.getTrackMetadata();
2699 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2701 handleNotificationSound(notificationURL, coordinator);
2702 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2703 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2704 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2705 restoreLastTransportState(coordinator, nextAction);
2710 * Does a chain of predefined actions when a Notification sound is played by
2711 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2712 * line in is currently loaded
2714 * @param currentLineInURI - the currently loaded line-in URI
2715 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2716 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2717 * @throws InterruptedException
2719 private void handleNotifForLineIn(@Nullable String currentLineInURI, Command notificationURL,
2720 ZonePlayerHandler coordinator) throws InterruptedException {
2721 logger.debug("Handling notification while sound from line-in was being played");
2722 String nextAction = coordinator.getTransportState();
2724 handleNotificationSound(notificationURL, coordinator);
2725 if (currentLineInURI != null) {
2726 logger.debug("Restoring sound from line-in using URI {}", currentLineInURI);
2727 coordinator.setCurrentURI(currentLineInURI, "");
2728 restoreLastTransportState(coordinator, nextAction);
2733 * Does a chain of predefined actions when a Notification sound is played by
2734 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2735 * virtual line in is currently loaded
2737 * @param currentVirtualLineInURI - the currently loaded virtual line-in URI
2738 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2739 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2740 * @throws InterruptedException
2742 private void handleNotifForVirtualLineIn(@Nullable String currentVirtualLineInURI, Command notificationURL,
2743 ZonePlayerHandler coordinator) throws InterruptedException {
2744 logger.debug("Handling notification while sound from virtual line-in was being played");
2745 String nextAction = coordinator.getTransportState();
2746 String currentUriMetaData = coordinator.getCurrentURIMetadataAsString();
2748 handleNotificationSound(notificationURL, coordinator);
2749 if (currentVirtualLineInURI != null && currentUriMetaData != null) {
2750 logger.debug("Restoring sound from virtual line-in using URI {} and metadata {}", currentVirtualLineInURI,
2751 currentUriMetaData);
2752 coordinator.setCurrentURI(currentVirtualLineInURI, currentUriMetaData);
2753 restoreLastTransportState(coordinator, nextAction);
2758 * Does a chain of predefined actions when a Notification sound is played by
2759 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2760 * shared queue is currently loaded
2762 * @param currentQueueURI - the currently loaded queue URI
2763 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2764 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2765 * @throws InterruptedException
2767 private void handleNotifForSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2768 ZonePlayerHandler coordinator) throws InterruptedException {
2769 String nextAction = coordinator.getTransportState();
2770 String trackPosition = coordinator.getRefreshedPosition();
2771 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2773 "Handling notification while playing queue: currentQueueURI {} trackPosition {} currentTrackNumber {}",
2774 currentQueueURI, trackPosition, currentTrackNumber);
2776 handleNotificationSound(notificationURL, coordinator);
2777 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2778 if (queueUri.equals(currentQueueURI)) {
2779 coordinator.setPositionTrack(currentTrackNumber);
2780 coordinator.setPosition(trackPosition);
2781 restoreLastTransportState(coordinator, nextAction);
2786 * Handle the execution of the notification sound by sequentially executing the required steps.
2788 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2789 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2790 * @throws InterruptedException
2792 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator)
2793 throws InterruptedException {
2794 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2795 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2796 if (sourceStoppable) {
2798 coordinator.waitForNotTransportState(STATE_PLAYING);
2799 applyNotificationSoundVolume();
2801 long notificationPosition = coordinator.getQueueSize() + 1;
2802 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2803 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2804 coordinator.setPositionTrack(notificationPosition);
2805 if (!sourceStoppable) {
2807 coordinator.waitForNotTransportState(STATE_PLAYING);
2808 applyNotificationSoundVolume();
2811 coordinator.waitForFinishedNotification();
2812 if (originalVolume != null) {
2813 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2815 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2818 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction)
2819 throws InterruptedException {
2820 if (nextAction != null) {
2821 switch (nextAction) {
2824 coordinator.waitForTransportState(STATE_PLAYING);
2826 case STATE_PAUSED_PLAYBACK:
2827 coordinator.pause();
2834 * Does a chain of predefined actions when a Notification sound is played by
2835 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2836 * empty queue is currently loaded
2838 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2839 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2840 * @throws InterruptedException
2842 private void handleNotifForEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator)
2843 throws InterruptedException {
2844 String originalVolume = coordinator.getVolume();
2845 coordinator.applyNotificationSoundVolume();
2846 coordinator.playURI(notificationURL);
2847 coordinator.waitForFinishedNotification();
2848 coordinator.removeAllTracksFromQueue();
2849 if (originalVolume != null) {
2850 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2855 * Applies the notification sound volume level to the group (if not null)
2857 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2859 private void applyNotificationSoundVolume() {
2860 setNotificationSoundVolume(getNotificationSoundVolume());
2863 private void waitForFinishedNotification() throws InterruptedException {
2864 waitForTransportState(STATE_PLAYING);
2866 // check Sonos state events to determine the end of the notification sound
2867 String notificationTitle = getCurrentTitle();
2868 long playstart = System.currentTimeMillis();
2869 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2871 String currentTitle = getCurrentTitle();
2872 if ((notificationTitle == null && currentTitle != null)
2873 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2874 || !STATE_PLAYING.equals(getTransportState())) {
2880 private void waitForTransportState(String state) throws InterruptedException {
2881 if (getTransportState() != null) {
2882 long start = System.currentTimeMillis();
2883 while (!state.equals(getTransportState())) {
2885 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2892 private void waitForNotTransportState(String state) throws InterruptedException {
2893 if (getTransportState() != null) {
2894 long start = System.currentTimeMillis();
2895 while (state.equals(getTransportState())) {
2897 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2905 * Removes a range of tracks from the queue.
2906 * (<x,y> will remove y songs started by the song number x)
2908 * @param command - must be in the format <startIndex, numberOfSongs>
2910 public void removeRangeOfTracksFromQueue(Command command) {
2911 if (command instanceof StringType) {
2912 String[] rangeInputSplit = command.toString().split(",");
2913 // If range input is incorrect, remove the first song by default
2914 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2915 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2916 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE,
2917 Map.of("InstanceID", "0", "StartingIndex", startIndex, "NumberOfTracks", numberOfTracks));
2921 public void clearQueue() {
2923 ZonePlayerHandler coordinator = getCoordinatorHandler();
2925 coordinator.removeAllTracksFromQueue();
2926 } catch (IllegalStateException e) {
2927 logger.debug("Cannot clear queue ({})", e.getMessage());
2931 public void playQueue() {
2933 ZonePlayerHandler coordinator = getCoordinatorHandler();
2935 // set the current playlist to our new queue
2936 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2938 // take the system off mute
2939 coordinator.setMute(OnOffType.OFF);
2943 } catch (IllegalStateException e) {
2944 logger.debug("Cannot play queue ({})", e.getMessage());
2948 public void setLed(Command command) {
2949 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2950 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2951 || command.equals(OpenClosedType.OPEN)) ? "On" : "Off";
2952 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_SET_LED_STATE, Map.of("DesiredLEDState", value));
2953 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
2957 public void removeMember(Command command) {
2958 if (command instanceof StringType) {
2960 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2962 oldmemberHandler.becomeStandAlonePlayer();
2963 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2964 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2965 oldmemberHandler.setCurrentURI(entry);
2966 } catch (IllegalStateException e) {
2967 logger.debug("Cannot remove group member ({})", e.getMessage());
2972 public void previous() {
2973 executeAction(SERVICE_AV_TRANSPORT, ACTION_PREVIOUS, null);
2976 public void next() {
2977 executeAction(SERVICE_AV_TRANSPORT, ACTION_NEXT, null);
2980 public void stopPlaying(Command command) {
2981 if (command instanceof OnOffType) {
2983 getCoordinatorHandler().stop();
2984 } catch (IllegalStateException e) {
2985 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2990 public void playRadio(Command command) {
2991 if (command instanceof StringType) {
2992 String station = command.toString();
2993 List<SonosEntry> stations = getFavoriteRadios();
2995 SonosEntry theEntry = null;
2996 // search for the appropriate radio based on its name (title)
2997 for (SonosEntry someStation : stations) {
2998 if (someStation.getTitle().equals(station)) {
2999 theEntry = someStation;
3004 // set the URI of the group coordinator
3005 if (theEntry != null) {
3007 ZonePlayerHandler coordinator = getCoordinatorHandler();
3008 coordinator.setCurrentURI(theEntry);
3010 } catch (IllegalStateException e) {
3011 logger.debug("Cannot play radio ({})", e.getMessage());
3014 logger.debug("Radio station '{}' not found", station);
3019 public void playTuneinStation(Command command) {
3020 if (command instanceof StringType) {
3021 String stationId = command.toString();
3022 List<SonosMusicService> allServices = getAvailableMusicServices();
3024 SonosMusicService tuneinService = null;
3025 // search for the TuneIn music service based on its name
3026 if (allServices != null) {
3027 for (SonosMusicService service : allServices) {
3028 if ("TuneIn".equals(service.getName())) {
3029 tuneinService = service;
3035 // set the URI of the group coordinator
3036 if (tuneinService != null) {
3038 ZonePlayerHandler coordinator = getCoordinatorHandler();
3039 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
3040 "object.item.audioItem.audioBroadcast",
3041 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
3042 Integer tuneinServiceType = tuneinService.getType();
3043 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
3044 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
3045 coordinator.setCurrentURI(entry);
3047 } catch (IllegalStateException e) {
3048 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
3051 logger.debug("TuneIn service not found");
3056 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
3057 if (musicServices == null) {
3058 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
3060 String serviceList = result.get("AvailableServiceDescriptorList");
3061 if (serviceList != null) {
3062 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
3063 musicServices = services;
3065 String[] servicesTypes = new String[0];
3066 String serviceTypeList = result.get("AvailableServiceTypeList");
3067 if (serviceTypeList != null) {
3068 // It is a comma separated list of service types (integers) in the same order as the services
3069 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
3071 servicesTypes = serviceTypeList.split(",");
3075 for (SonosMusicService service : services) {
3076 if (!"TuneIn".equals(service.getName())) {
3077 // Add the service type integer value from "AvailableServiceTypeList" to each service
3079 if (idx < servicesTypes.length) {
3081 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
3082 service.setType(serviceType);
3083 } catch (NumberFormatException e) {
3088 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3090 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3095 return musicServices;
3099 * This will attempt to match the station string with a entry in the
3100 * favorites list, this supports both single entries and playlists
3102 * @param favorite to match
3103 * @return true if a match was found and played.
3105 public void playFavorite(Command command) {
3106 if (command instanceof StringType) {
3107 String favorite = command.toString();
3108 List<SonosEntry> favorites = getFavorites();
3110 SonosEntry theEntry = null;
3111 // search for the appropriate favorite based on its name (title)
3112 for (SonosEntry entry : favorites) {
3113 if (entry.getTitle().equals(favorite)) {
3119 // set the URI of the group coordinator
3120 if (theEntry != null) {
3122 ZonePlayerHandler coordinator = getCoordinatorHandler();
3125 * If this is a playlist we need to treat it as such
3127 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3128 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3129 coordinator.removeAllTracksFromQueue();
3130 coordinator.addURIToQueue(theEntry);
3131 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3132 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3133 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3135 coordinator.setCurrentURI(theEntry);
3138 } catch (IllegalStateException e) {
3139 logger.debug("Cannot paly favorite ({})", e.getMessage());
3142 logger.debug("Favorite '{}' not found", favorite);
3147 public void playTrack(Command command) {
3148 if (command instanceof DecimalType) {
3150 ZonePlayerHandler coordinator = getCoordinatorHandler();
3152 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3154 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3156 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3157 coordinator.setPositionTrack(trackNumber);
3159 // take the system off mute
3160 coordinator.setMute(OnOffType.OFF);
3164 } catch (IllegalStateException e) {
3165 logger.debug("Cannot play track ({})", e.getMessage());
3170 public void playPlayList(Command command) {
3171 if (command instanceof StringType) {
3172 String playlist = command.toString();
3173 List<SonosEntry> playlists = getPlayLists();
3175 SonosEntry theEntry = null;
3176 // search for the appropriate play list based on its name (title)
3177 for (SonosEntry somePlaylist : playlists) {
3178 if (somePlaylist.getTitle().equals(playlist)) {
3179 theEntry = somePlaylist;
3184 // set the URI of the group coordinator
3185 if (theEntry != null) {
3187 ZonePlayerHandler coordinator = getCoordinatorHandler();
3189 coordinator.addURIToQueue(theEntry);
3191 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3193 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3194 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3197 } catch (IllegalStateException e) {
3198 logger.debug("Cannot play playlist ({})", e.getMessage());
3201 logger.debug("Playlist '{}' not found", playlist);
3206 public void addURIToQueue(SonosEntry newEntry) {
3207 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3210 public @Nullable String getZoneName() {
3211 return stateMap.get("ZoneName");
3214 public @Nullable String getZoneGroupID() {
3215 return stateMap.get("LocalGroupUUID");
3218 public @Nullable String getRunningAlarmProperties() {
3219 return stateMap.get("RunningAlarmProperties");
3222 public @Nullable String getRefreshedRunningAlarmProperties() {
3223 updateRunningAlarmProperties();
3224 return getRunningAlarmProperties();
3227 public @Nullable String getMute() {
3228 return stateMap.get("MuteMaster");
3231 public @Nullable String getLed() {
3232 return stateMap.get("CurrentLEDState");
3235 public @Nullable String getCurrentZoneName() {
3236 return stateMap.get("CurrentZoneName");
3239 public @Nullable String getRefreshedCurrentZoneName() {
3240 updateCurrentZoneName();
3241 return getCurrentZoneName();
3245 public void onStatusChanged(boolean status) {
3247 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3248 if (getThing().getStatus() != ThingStatus.ONLINE) {
3249 updateStatus(ThingStatus.ONLINE);
3250 scheduler.execute(this::poll);
3253 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3254 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3258 private @Nullable String getModelNameFromDescriptor() {
3259 URL descriptor = service.getDescriptorURL(this);
3260 if (descriptor != null) {
3261 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3262 return sonosModelDescription == null ? null
3263 : SonosXMLParser.buildThingTypeIdFromModelName(sonosModelDescription);
3269 private boolean migrateThingType() {
3270 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3271 String modelName = getModelNameFromDescriptor();
3272 if (modelName != null && isSupportedModel(modelName)) {
3273 updateSonosThingType(modelName);
3280 private boolean isSupportedModel(String modelName) {
3281 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3282 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3289 private void updateSonosThingType(String newThingTypeID) {
3290 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3294 * Set the sleeptimer duration
3295 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3296 * Use empty String "" to switch the sleep timer off
3298 public void setSleepTimer(Command command) {
3299 if (command instanceof DecimalType) {
3300 this.service.invokeAction(this, SERVICE_AV_TRANSPORT, ACTION_CONFIGURE_SLEEP_TIMER, Map.of("InstanceID",
3301 "0", "NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue())));
3305 protected void updateSleepTimerDuration() {
3306 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_REMAINING_SLEEP_TIMER_DURATION, null);
3309 private String sleepSecondsToTimeStr(long sleepSeconds) {
3310 if (sleepSeconds == 0) {
3312 } else if (sleepSeconds < 68400) {
3313 long remainingSeconds = sleepSeconds;
3314 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3315 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3316 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3317 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3318 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3319 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3321 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3326 private long sleepStrTimeToSeconds(String sleepTime) {
3327 String[] units = sleepTime.split(":");
3328 int hours = Integer.parseInt(units[0]);
3329 int minutes = Integer.parseInt(units[1]);
3330 int seconds = Integer.parseInt(units[2]);
3331 return 3600 * hours + 60 * minutes + seconds;
3334 private @Nullable String extractInfoFromMoreInfo(String searchedInfo) {
3335 String value = stateMap.get("MoreInfo");
3336 if (value != null) {
3337 String[] fields = value.split(",");
3338 for (int i = 0; i < fields.length; i++) {
3339 String[] pair = fields[i].trim().split(":");
3340 if (pair.length == 2 && searchedInfo.equalsIgnoreCase(pair[0].trim())) {
3341 return pair[1].trim();