]> git.basschouten.com Git - openhab-addons.git/blob
bf1b9ad5cfe37f9740fd3c4be8ced9877acf931f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.sonos.internal.handler;
14
15 import static org.openhab.binding.sonos.internal.SonosBindingConstants.*;
16
17 import java.io.IOException;
18 import java.net.MalformedURLException;
19 import java.net.URL;
20 import java.text.ParseException;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.Arrays;
24 import java.util.Calendar;
25 import java.util.Collection;
26 import java.util.Collections;
27 import java.util.Date;
28 import java.util.HashMap;
29 import java.util.List;
30 import java.util.Map;
31 import java.util.TimeZone;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
34
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.openhab.binding.sonos.internal.SonosAlarm;
38 import org.openhab.binding.sonos.internal.SonosBindingConstants;
39 import org.openhab.binding.sonos.internal.SonosEntry;
40 import org.openhab.binding.sonos.internal.SonosMetaData;
41 import org.openhab.binding.sonos.internal.SonosMusicService;
42 import org.openhab.binding.sonos.internal.SonosResourceMetaData;
43 import org.openhab.binding.sonos.internal.SonosStateDescriptionOptionProvider;
44 import org.openhab.binding.sonos.internal.SonosXMLParser;
45 import org.openhab.binding.sonos.internal.SonosZoneGroup;
46 import org.openhab.binding.sonos.internal.SonosZonePlayerState;
47 import org.openhab.binding.sonos.internal.config.ZonePlayerConfiguration;
48 import org.openhab.core.io.net.http.HttpUtil;
49 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
50 import org.openhab.core.io.transport.upnp.UpnpIOService;
51 import org.openhab.core.library.types.DecimalType;
52 import org.openhab.core.library.types.IncreaseDecreaseType;
53 import org.openhab.core.library.types.NextPreviousType;
54 import org.openhab.core.library.types.OnOffType;
55 import org.openhab.core.library.types.OpenClosedType;
56 import org.openhab.core.library.types.PercentType;
57 import org.openhab.core.library.types.PlayPauseType;
58 import org.openhab.core.library.types.RawType;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.library.types.UpDownType;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.Thing;
63 import org.openhab.core.thing.ThingRegistry;
64 import org.openhab.core.thing.ThingStatus;
65 import org.openhab.core.thing.ThingStatusDetail;
66 import org.openhab.core.thing.ThingTypeUID;
67 import org.openhab.core.thing.ThingUID;
68 import org.openhab.core.thing.binding.BaseThingHandler;
69 import org.openhab.core.thing.binding.ThingHandler;
70 import org.openhab.core.types.Command;
71 import org.openhab.core.types.RefreshType;
72 import org.openhab.core.types.State;
73 import org.openhab.core.types.StateOption;
74 import org.openhab.core.types.UnDefType;
75 import org.slf4j.Logger;
76 import org.slf4j.LoggerFactory;
77
78 /**
79  * The {@link ZonePlayerHandler} is responsible for handling commands, which are
80  * sent to one of the channels.
81  *
82  * @author Karel Goderis - Initial contribution
83  */
84 @NonNullByDefault
85 public class ZonePlayerHandler extends BaseThingHandler implements UpnpIOParticipant {
86
87     private static final String ANALOG_LINE_IN_URI = "x-rincon-stream:";
88     private static final String OPTICAL_LINE_IN_URI = "x-sonos-htastream:";
89     private static final String QUEUE_URI = "x-rincon-queue:";
90     private static final String GROUP_URI = "x-rincon:";
91     private static final String STREAM_URI = "x-sonosapi-stream:";
92     private static final String RADIO_URI = "x-sonosapi-radio:";
93     private static final String RADIO_MP3_URI = "x-rincon-mp3radio:";
94     private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
95     private static final String FILE_URI = "x-file-cifs:";
96     private static final String SPDIF = ":spdif";
97     private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
98
99     private static final String STATE_PLAYING = "PLAYING";
100     private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
101     private static final String STATE_STOPPED = "STOPPED";
102
103     private static final String LINEINCONNECTED = "LineInConnected";
104     private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
105
106     private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList("DeviceProperties", "AVTransport",
107             "ZoneGroupTopology", "GroupManagement", "RenderingControl", "AudioIn", "HTControl", "ContentDirectory");
108     protected static final int SUBSCRIPTION_DURATION = 1800;
109
110     private static final int SOCKET_TIMEOUT = 5000;
111
112     private static final int TUNEIN_DEFAULT_SERVICE_TYPE = 65031;
113
114     private static final int MIN_SUBWOOFER_GAIN = -15;
115     private static final int MAX_SUBWOOFER_GAIN = 15;
116     private static final int MIN_SURROUND_LEVEL = -15;
117     private static final int MAX_SURROUND_LEVEL = 15;
118
119     private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
120
121     private final ThingRegistry localThingRegistry;
122     private final UpnpIOService service;
123     private final @Nullable String opmlUrl;
124     private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
125
126     private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
127
128     /**
129      * Intrinsic lock used to synchronize the execution of notification sounds
130      */
131     private final Object notificationLock = new Object();
132     private final Object upnpLock = new Object();
133     private final Object stateLock = new Object();
134     private final Object jobLock = new Object();
135
136     private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
137
138     private @Nullable ScheduledFuture<?> pollingJob;
139     private @Nullable SonosZonePlayerState savedState;
140
141     private Map<String, Boolean> subscriptionState = new HashMap<>();
142
143     /**
144      * Thing handler instance of the coordinator speaker used for control delegation
145      */
146     private @Nullable ZonePlayerHandler coordinatorHandler;
147
148     private @Nullable List<SonosMusicService> musicServices;
149
150     private enum LineInType {
151         ANALOG,
152         DIGITAL,
153         ANY
154     }
155
156     public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
157             @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
158         super(thing);
159         this.localThingRegistry = thingRegistry;
160         this.opmlUrl = opmlUrl;
161         logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
162         this.service = upnpIOService;
163         this.stateDescriptionProvider = stateDescriptionProvider;
164     }
165
166     @Override
167     public void dispose() {
168         logger.debug("Handler disposed for thing {}", getThing().getUID());
169
170         ScheduledFuture<?> job = this.pollingJob;
171         if (job != null) {
172             job.cancel(true);
173         }
174         this.pollingJob = null;
175
176         removeSubscription();
177         service.unregisterParticipant(this);
178     }
179
180     @Override
181     public void initialize() {
182         logger.debug("initializing handler for thing {}", getThing().getUID());
183
184         if (migrateThingType()) {
185             // we change the type, so we might need a different handler -> let's finish
186             return;
187         }
188
189         configuration = getConfigAs(ZonePlayerConfiguration.class);
190         String udn = configuration.udn;
191         if (udn != null && !udn.isEmpty()) {
192             service.registerParticipant(this);
193             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
194         } else {
195             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
196                     "@text/offline.conf-error-missing-udn");
197             logger.debug("Cannot initalize the zoneplayer. UDN not set.");
198         }
199     }
200
201     private void poll() {
202         synchronized (jobLock) {
203             if (pollingJob == null) {
204                 return;
205             }
206             try {
207                 logger.debug("Polling job");
208
209                 // First check if the Sonos zone is set in the UPnP service registry
210                 // If not, set the thing state to OFFLINE and wait for the next poll
211                 if (!isUpnpDeviceRegistered()) {
212                     logger.debug("UPnP device {} not yet registered", getUDN());
213                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
214                             "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
215                     synchronized (upnpLock) {
216                         subscriptionState = new HashMap<>();
217                     }
218                     return;
219                 }
220
221                 // Check if the Sonos zone can be joined
222                 // If not, set the thing state to OFFLINE and do nothing else
223                 updatePlayerState();
224                 if (getThing().getStatus() != ThingStatus.ONLINE) {
225                     return;
226                 }
227
228                 addSubscription();
229
230                 if (isLinked(ZONENAME)) {
231                     updateCurrentZoneName();
232                 }
233                 if (isLinked(LED)) {
234                     updateLed();
235                 }
236                 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
237                 if (isLinked(SLEEPTIMER) && isCoordinator()) {
238                     updateSleepTimerDuration();
239                 }
240             } catch (Exception e) {
241                 logger.debug("Exception during poll: {}", e.getMessage(), e);
242             }
243         }
244     }
245
246     @Override
247     public void handleCommand(ChannelUID channelUID, Command command) {
248         if (command == RefreshType.REFRESH) {
249             updateChannel(channelUID.getId());
250         } else {
251             switch (channelUID.getId()) {
252                 case LED:
253                     setLed(command);
254                     break;
255                 case MUTE:
256                     setMute(command);
257                     break;
258                 case NOTIFICATIONSOUND:
259                     scheduleNotificationSound(command);
260                     break;
261                 case STOP:
262                     stopPlaying(command);
263                     break;
264                 case VOLUME:
265                     setVolumeForGroup(command);
266                     break;
267                 case SUBWOOFER:
268                     setSubwoofer(command);
269                     break;
270                 case SUBWOOFERGAIN:
271                     setSubwooferGain(command);
272                     break;
273                 case SURROUND:
274                     setSurround(command);
275                     break;
276                 case SURROUNDMUSICMODE:
277                     setSurroundMusicMode(command);
278                     break;
279                 case SURROUNDMUSICLEVEL:
280                     setSurroundMusicLevel(command);
281                     break;
282                 case SURROUNDTVLEVEL:
283                     setSurroundTvLevel(command);
284                     break;
285                 case ADD:
286                     addMember(command);
287                     break;
288                 case REMOVE:
289                     removeMember(command);
290                     break;
291                 case STANDALONE:
292                     becomeStandAlonePlayer();
293                     break;
294                 case PUBLICADDRESS:
295                     publicAddress(LineInType.ANY);
296                     break;
297                 case PUBLICANALOGADDRESS:
298                     publicAddress(LineInType.ANALOG);
299                     break;
300                 case PUBLICDIGITALADDRESS:
301                     publicAddress(LineInType.DIGITAL);
302                     break;
303                 case RADIO:
304                     playRadio(command);
305                     break;
306                 case TUNEINSTATIONID:
307                     playTuneinStation(command);
308                     break;
309                 case FAVORITE:
310                     playFavorite(command);
311                     break;
312                 case ALARM:
313                     setAlarm(command);
314                     break;
315                 case SNOOZE:
316                     snoozeAlarm(command);
317                     break;
318                 case SAVEALL:
319                     saveAllPlayerState();
320                     break;
321                 case RESTOREALL:
322                     restoreAllPlayerState();
323                     break;
324                 case SAVE:
325                     saveState();
326                     break;
327                 case RESTORE:
328                     restoreState();
329                     break;
330                 case PLAYLIST:
331                     playPlayList(command);
332                     break;
333                 case CLEARQUEUE:
334                     clearQueue();
335                     break;
336                 case PLAYQUEUE:
337                     playQueue();
338                     break;
339                 case PLAYTRACK:
340                     playTrack(command);
341                     break;
342                 case PLAYURI:
343                     playURI(command);
344                     break;
345                 case PLAYLINEIN:
346                     playLineIn(command);
347                     break;
348                 case CONTROL:
349                     try {
350                         if (command instanceof PlayPauseType) {
351                             if (command == PlayPauseType.PLAY) {
352                                 getCoordinatorHandler().play();
353                             } else if (command == PlayPauseType.PAUSE) {
354                                 getCoordinatorHandler().pause();
355                             }
356                         }
357                         if (command instanceof NextPreviousType) {
358                             if (command == NextPreviousType.NEXT) {
359                                 getCoordinatorHandler().next();
360                             } else if (command == NextPreviousType.PREVIOUS) {
361                                 getCoordinatorHandler().previous();
362                             }
363                         }
364                         // Rewind and Fast Forward are currently not implemented by the binding
365                     } catch (IllegalStateException e) {
366                         logger.debug("Cannot handle control command ({})", e.getMessage());
367                     }
368                     break;
369                 case SLEEPTIMER:
370                     setSleepTimer(command);
371                     break;
372                 case SHUFFLE:
373                     setShuffle(command);
374                     break;
375                 case REPEAT:
376                     setRepeat(command);
377                     break;
378                 case NIGHTMODE:
379                     setNightMode(command);
380                     break;
381                 case SPEECHENHANCEMENT:
382                     setSpeechEnhancement(command);
383                     break;
384                 default:
385                     break;
386             }
387         }
388     }
389
390     private void restoreAllPlayerState() {
391         for (Thing aThing : localThingRegistry.getAll()) {
392             if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
393                 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
394                 if (handler != null) {
395                     handler.restoreState();
396                 }
397             }
398         }
399     }
400
401     private void saveAllPlayerState() {
402         for (Thing aThing : localThingRegistry.getAll()) {
403             if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
404                 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
405                 if (handler != null) {
406                     handler.saveState();
407                 }
408             }
409         }
410     }
411
412     @Override
413     public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
414         if (variable == null || value == null || service == null) {
415             return;
416         }
417
418         if (getThing().getStatus() == ThingStatus.ONLINE) {
419             logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
420                     new Object[] { variable, value, service, this.getThing().getUID() });
421
422             String oldValue = this.stateMap.get(variable);
423             if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
424                 return;
425             }
426
427             this.stateMap.put(variable, value);
428
429             // pre-process some variables, eg XML processing
430             if (service.equals("AVTransport") && variable.equals("LastChange")) {
431                 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
432                 for (String parsedValue : parsedValues.keySet()) {
433                     // Update the transport state after the update of the media information
434                     // to not break the notification mechanism
435                     if (!parsedValue.equals("TransportState")) {
436                         onValueReceived(parsedValue, parsedValues.get(parsedValue), "AVTransport");
437                     }
438                     // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
439                     // for a compatibility with the result of the action GetMediaInfo
440                     if (parsedValue.equals("AVTransportURI")) {
441                         onValueReceived("CurrentURI", parsedValues.get(parsedValue), service);
442                     } else if (parsedValue.equals("AVTransportURIMetaData")) {
443                         onValueReceived("CurrentURIMetaData", parsedValues.get(parsedValue), service);
444                     }
445                 }
446                 updateMediaInformation();
447                 if (parsedValues.get("TransportState") != null) {
448                     onValueReceived("TransportState", parsedValues.get("TransportState"), "AVTransport");
449                 }
450             }
451
452             if (service.equals("RenderingControl") && variable.equals("LastChange")) {
453                 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
454                 for (String parsedValue : parsedValues.keySet()) {
455                     onValueReceived(parsedValue, parsedValues.get(parsedValue), "RenderingControl");
456                 }
457             }
458
459             List<StateOption> options = new ArrayList<>();
460
461             // update the appropriate channel
462             switch (variable) {
463                 case "TransportState":
464                     updateChannel(STATE);
465                     updateChannel(CONTROL);
466                     updateChannel(STOP);
467                     dispatchOnAllGroupMembers(variable, value, service);
468                     break;
469                 case "CurrentPlayMode":
470                     updateChannel(SHUFFLE);
471                     updateChannel(REPEAT);
472                     dispatchOnAllGroupMembers(variable, value, service);
473                     break;
474                 case "CurrentLEDState":
475                     updateChannel(LED);
476                     break;
477                 case "ZoneName":
478                     updateState(ZONENAME, new StringType(value));
479                     break;
480                 case "CurrentZoneName":
481                     updateChannel(ZONENAME);
482                     break;
483                 case "ZoneGroupState":
484                     updateChannel(COORDINATOR);
485                     // Update coordinator after a change is made to the grouping of Sonos players
486                     updateGroupCoordinator();
487                     updateMediaInformation();
488                     // Update state and control channels for the group members with the coordinator values
489                     String transportState = getTransportState();
490                     if (transportState != null) {
491                         dispatchOnAllGroupMembers("TransportState", transportState, "AVTransport");
492                     }
493                     // Update shuffle and repeat channels for the group members with the coordinator values
494                     String playMode = getPlayMode();
495                     if (playMode != null) {
496                         dispatchOnAllGroupMembers("CurrentPlayMode", playMode, "AVTransport");
497                     }
498                     break;
499                 case "LocalGroupUUID":
500                     updateChannel(ZONEGROUPID);
501                     break;
502                 case "GroupCoordinatorIsLocal":
503                     updateChannel(LOCALCOORDINATOR);
504                     break;
505                 case "VolumeMaster":
506                     updateChannel(VOLUME);
507                     break;
508                 case "MuteMaster":
509                     updateChannel(MUTE);
510                     break;
511                 case "SubEnabled":
512                     updateChannel(SUBWOOFER);
513                     break;
514                 case "SubGain":
515                     updateChannel(SUBWOOFERGAIN);
516                     break;
517                 case "SurroundEnabled":
518                     updateChannel(SURROUND);
519                     break;
520                 case "SurroundMode":
521                     updateChannel(SURROUNDMUSICMODE);
522                     break;
523                 case "SurroundLevel":
524                     updateChannel(SURROUNDTVLEVEL);
525                     break;
526                 case "MusicSurroundLevel":
527                     updateChannel(SURROUNDMUSICLEVEL);
528                     break;
529                 case "NightMode":
530                     updateChannel(NIGHTMODE);
531                     break;
532                 case "DialogLevel":
533                     updateChannel(SPEECHENHANCEMENT);
534                     break;
535                 case LINEINCONNECTED:
536                     if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
537                         updateChannel(LINEIN);
538                     }
539                     if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
540                             .contains(getThing().getThingTypeUID())) {
541                         updateChannel(ANALOGLINEIN);
542                     }
543                     break;
544                 case TOSLINEINCONNECTED:
545                     if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
546                         updateChannel(LINEIN);
547                     }
548                     if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
549                             .contains(getThing().getThingTypeUID())) {
550                         updateChannel(DIGITALLINEIN);
551                     }
552                     break;
553                 case "AlarmRunning":
554                     updateChannel(ALARMRUNNING);
555                     updateRunningAlarmProperties();
556                     break;
557                 case "RunningAlarmProperties":
558                     updateChannel(ALARMPROPERTIES);
559                     break;
560                 case "CurrentURIFormatted":
561                     updateChannel(CURRENTTRACK);
562                     break;
563                 case "CurrentTitle":
564                     updateChannel(CURRENTTITLE);
565                     break;
566                 case "CurrentArtist":
567                     updateChannel(CURRENTARTIST);
568                     break;
569                 case "CurrentAlbum":
570                     updateChannel(CURRENTALBUM);
571                     break;
572                 case "CurrentURI":
573                     updateChannel(CURRENTTRANSPORTURI);
574                     break;
575                 case "CurrentTrackURI":
576                     updateChannel(CURRENTTRACKURI);
577                     break;
578                 case "CurrentAlbumArtURI":
579                     updateChannel(CURRENTALBUMARTURL);
580                     break;
581                 case "CurrentSleepTimerGeneration":
582                     if (value.equals("0")) {
583                         updateState(SLEEPTIMER, new DecimalType(0));
584                     }
585                     break;
586                 case "SleepTimerGeneration":
587                     if (value.equals("0")) {
588                         updateState(SLEEPTIMER, new DecimalType(0));
589                     } else {
590                         updateSleepTimerDuration();
591                     }
592                     break;
593                 case "RemainingSleepTimerDuration":
594                     updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
595                     break;
596                 case "CurrentTuneInStationId":
597                     updateChannel(TUNEINSTATIONID);
598                     break;
599                 case "SavedQueuesUpdateID": // service ContentDirectoy
600                     for (SonosEntry entry : getPlayLists()) {
601                         options.add(new StateOption(entry.getTitle(), entry.getTitle()));
602                     }
603                     stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
604                     break;
605                 case "FavoritesUpdateID": // service ContentDirectoy
606                     for (SonosEntry entry : getFavorites()) {
607                         options.add(new StateOption(entry.getTitle(), entry.getTitle()));
608                     }
609                     stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
610                     break;
611                 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
612                 // Due to a bug in the data type definition of this state variable, it is not set.
613                 // As a workaround, we check the state variable named ContainerUpdateIDs.
614                 case "ContainerUpdateIDs": // service ContentDirectoy
615                     if (value.startsWith("R:0,") || stateDescriptionProvider
616                             .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
617                         for (SonosEntry entry : getFavoriteRadios()) {
618                             options.add(new StateOption(entry.getTitle(), entry.getTitle()));
619                         }
620                         stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
621                     }
622                     break;
623                 default:
624                     break;
625             }
626         }
627     }
628
629     private void dispatchOnAllGroupMembers(String variable, String value, String service) {
630         if (isCoordinator()) {
631             for (String member : getOtherZoneGroupMembers()) {
632                 try {
633                     ZonePlayerHandler memberHandler = getHandlerByName(member);
634                     if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
635                         memberHandler.onValueReceived(variable, value, service);
636                     }
637                 } catch (IllegalStateException e) {
638                     logger.debug("Cannot update channel for group member ({})", e.getMessage());
639                 }
640             }
641         }
642     }
643
644     private @Nullable String getAlbumArtUrl() {
645         String url = null;
646         String albumArtURI = stateMap.get("CurrentAlbumArtURI");
647         if (albumArtURI != null) {
648             if (albumArtURI.startsWith("http")) {
649                 url = albumArtURI;
650             } else if (albumArtURI.startsWith("/")) {
651                 try {
652                     URL serviceDescrUrl = service.getDescriptorURL(this);
653                     if (serviceDescrUrl != null) {
654                         url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
655                                 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
656                     }
657                 } catch (MalformedURLException e) {
658                     logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
659                 }
660             }
661         }
662         return url;
663     }
664
665     protected void updateChannel(String channelId) {
666         if (!isLinked(channelId)) {
667             return;
668         }
669
670         String url;
671
672         State newState = UnDefType.UNDEF;
673         String value;
674         switch (channelId) {
675             case STATE:
676                 value = getTransportState();
677                 if (value != null) {
678                     newState = new StringType(value);
679                 }
680                 break;
681             case CONTROL:
682                 value = getTransportState();
683                 if (STATE_PLAYING.equals(value)) {
684                     newState = PlayPauseType.PLAY;
685                 } else if (STATE_STOPPED.equals(value)) {
686                     newState = PlayPauseType.PAUSE;
687                 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
688                     newState = PlayPauseType.PAUSE;
689                 }
690                 break;
691             case STOP:
692                 value = getTransportState();
693                 if (value != null) {
694                     newState = STATE_STOPPED.equals(value) ? OnOffType.ON : OnOffType.OFF;
695                 }
696                 break;
697             case SHUFFLE:
698                 if (getPlayMode() != null) {
699                     newState = isShuffleActive() ? OnOffType.ON : OnOffType.OFF;
700                 }
701                 break;
702             case REPEAT:
703                 if (getPlayMode() != null) {
704                     newState = new StringType(getRepeatMode());
705                 }
706                 break;
707             case LED:
708                 if (getLed() != null) {
709                     newState = isLedOn() ? OnOffType.ON : OnOffType.OFF;
710                 }
711                 break;
712             case ZONENAME:
713                 value = getCurrentZoneName();
714                 if (value != null) {
715                     newState = new StringType(value);
716                 }
717                 break;
718             case ZONEGROUPID:
719                 value = getZoneGroupID();
720                 if (value != null) {
721                     newState = new StringType(value);
722                 }
723                 break;
724             case COORDINATOR:
725                 newState = new StringType(getCoordinator());
726                 break;
727             case LOCALCOORDINATOR:
728                 if (getGroupCoordinatorIsLocal() != null) {
729                     newState = isGroupCoordinator() ? OnOffType.ON : OnOffType.OFF;
730                 }
731                 break;
732             case VOLUME:
733                 value = getVolume();
734                 if (value != null) {
735                     newState = new PercentType(value);
736                 }
737                 break;
738             case MUTE:
739                 value = getMute();
740                 if (value != null) {
741                     newState = isMuted() ? OnOffType.ON : OnOffType.OFF;
742                 }
743                 break;
744             case SUBWOOFER:
745                 value = getSubwooferEnabled();
746                 if (value != null) {
747                     newState = OnOffType.from(value);
748                 }
749                 break;
750             case SUBWOOFERGAIN:
751                 value = getSubwooferGain();
752                 if (value != null) {
753                     newState = new DecimalType(value);
754                 }
755                 break;
756             case SURROUND:
757                 value = getSurroundEnabled();
758                 if (value != null) {
759                     newState = OnOffType.from(value);
760                 }
761                 break;
762             case SURROUNDMUSICMODE:
763                 value = getSurroundMusicMode();
764                 if (value != null) {
765                     newState = new StringType(value);
766                 }
767                 break;
768             case SURROUNDMUSICLEVEL:
769                 value = getSurroundMusicLevel();
770                 if (value != null) {
771                     newState = new DecimalType(value);
772                 }
773                 break;
774             case SURROUNDTVLEVEL:
775                 value = getSurroundTvLevel();
776                 if (value != null) {
777                     newState = new DecimalType(value);
778                 }
779                 break;
780             case NIGHTMODE:
781                 value = getNightMode();
782                 if (value != null) {
783                     newState = isNightModeOn() ? OnOffType.ON : OnOffType.OFF;
784                 }
785                 break;
786             case SPEECHENHANCEMENT:
787                 value = getDialogLevel();
788                 if (value != null) {
789                     newState = isSpeechEnhanced() ? OnOffType.ON : OnOffType.OFF;
790                 }
791                 break;
792             case LINEIN:
793                 if (getAnalogLineInConnected() != null) {
794                     newState = isAnalogLineInConnected() ? OnOffType.ON : OnOffType.OFF;
795                 } else if (getOpticalLineInConnected() != null) {
796                     newState = isOpticalLineInConnected() ? OnOffType.ON : OnOffType.OFF;
797                 }
798                 break;
799             case ANALOGLINEIN:
800                 if (getAnalogLineInConnected() != null) {
801                     newState = isAnalogLineInConnected() ? OnOffType.ON : OnOffType.OFF;
802                 }
803                 break;
804             case DIGITALLINEIN:
805                 if (getOpticalLineInConnected() != null) {
806                     newState = isOpticalLineInConnected() ? OnOffType.ON : OnOffType.OFF;
807                 }
808                 break;
809             case ALARMRUNNING:
810                 if (getAlarmRunning() != null) {
811                     newState = isAlarmRunning() ? OnOffType.ON : OnOffType.OFF;
812                 }
813                 break;
814             case ALARMPROPERTIES:
815                 value = getRunningAlarmProperties();
816                 if (value != null) {
817                     newState = new StringType(value);
818                 }
819                 break;
820             case CURRENTTRACK:
821                 value = stateMap.get("CurrentURIFormatted");
822                 if (value != null) {
823                     newState = new StringType(value);
824                 }
825                 break;
826             case CURRENTTITLE:
827                 value = getCurrentTitle();
828                 if (value != null) {
829                     newState = new StringType(value);
830                 }
831                 break;
832             case CURRENTARTIST:
833                 value = getCurrentArtist();
834                 if (value != null) {
835                     newState = new StringType(value);
836                 }
837                 break;
838             case CURRENTALBUM:
839                 value = getCurrentAlbum();
840                 if (value != null) {
841                     newState = new StringType(value);
842                 }
843                 break;
844             case CURRENTALBUMART:
845                 newState = null;
846                 updateAlbumArtChannel(false);
847                 break;
848             case CURRENTALBUMARTURL:
849                 url = getAlbumArtUrl();
850                 if (url != null) {
851                     newState = new StringType(url);
852                 }
853                 break;
854             case CURRENTTRANSPORTURI:
855                 value = getCurrentURI();
856                 if (value != null) {
857                     newState = new StringType(value);
858                 }
859                 break;
860             case CURRENTTRACKURI:
861                 value = stateMap.get("CurrentTrackURI");
862                 if (value != null) {
863                     newState = new StringType(value);
864                 }
865                 break;
866             case TUNEINSTATIONID:
867                 value = stateMap.get("CurrentTuneInStationId");
868                 if (value != null) {
869                     newState = new StringType(value);
870                 }
871                 break;
872             default:
873                 newState = null;
874                 break;
875         }
876         if (newState != null) {
877             updateState(channelId, newState);
878         }
879     }
880
881     private void updateAlbumArtChannel(boolean allGroup) {
882         String url = getAlbumArtUrl();
883         if (url != null) {
884             // We download the cover art in a different thread to not delay the other operations
885             scheduler.submit(() -> {
886                 RawType image = HttpUtil.downloadImage(url, true, 500000);
887                 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
888             });
889         } else {
890             updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
891         }
892     }
893
894     private void updateChannel(String channeldD, State state, boolean allGroup) {
895         if (allGroup) {
896             for (String member : getZoneGroupMembers()) {
897                 try {
898                     ZonePlayerHandler memberHandler = getHandlerByName(member);
899                     if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
900                             && memberHandler.isLinked(channeldD)) {
901                         memberHandler.updateState(channeldD, state);
902                     }
903                 } catch (IllegalStateException e) {
904                     logger.debug("Cannot update channel for group member ({})", e.getMessage());
905                 }
906             }
907         } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
908             updateState(channeldD, state);
909         }
910     }
911
912     /**
913      * CurrentURI will not change, but will trigger change of CurrentURIFormated
914      * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
915      */
916     private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
917         return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
918     }
919
920     private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
921         return oldValue != null ? !oldValue.equals(value) : value != null;
922     }
923
924     /**
925      * Similar to the AVTransport eventing, the Queue events its state variables
926      * as sub values within a synthesized LastChange state variable.
927      */
928     private boolean isQueueEvent(String variable) {
929         return "LastChange".equals(variable);
930     }
931
932     private void updateGroupCoordinator() {
933         try {
934             coordinatorHandler = getHandlerByName(getCoordinator());
935         } catch (IllegalStateException e) {
936             logger.debug("Cannot update the group coordinator ({})", e.getMessage());
937             coordinatorHandler = null;
938         }
939     }
940
941     private boolean isUpnpDeviceRegistered() {
942         return service.isRegistered(this);
943     }
944
945     private void addSubscription() {
946         synchronized (upnpLock) {
947             // Set up GENA Subscriptions
948             if (service.isRegistered(this)) {
949                 for (String subscription : SERVICE_SUBSCRIPTIONS) {
950                     Boolean state = subscriptionState.get(subscription);
951                     if (state == null || !state) {
952                         logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
953                         service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
954                         subscriptionState.put(subscription, true);
955                     }
956                 }
957             }
958         }
959     }
960
961     private void removeSubscription() {
962         synchronized (upnpLock) {
963             // Set up GENA Subscriptions
964             if (service.isRegistered(this)) {
965                 for (String subscription : SERVICE_SUBSCRIPTIONS) {
966                     Boolean state = subscriptionState.get(subscription);
967                     if (state != null && state) {
968                         logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
969                         service.removeSubscription(this, subscription);
970                     }
971                 }
972             }
973             subscriptionState = new HashMap<>();
974         }
975     }
976
977     @Override
978     public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
979         if (service == null) {
980             return;
981         }
982         synchronized (upnpLock) {
983             logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
984             subscriptionState.put(service, succeeded);
985         }
986     }
987
988     private void updatePlayerState() {
989         if (!updateZoneInfo()) {
990             if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
991                 logger.debug("Sonos player {} is not available in local network", getUDN());
992                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
993                         "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
994                 synchronized (upnpLock) {
995                     subscriptionState = new HashMap<>();
996                 }
997             }
998         } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
999             logger.debug("Sonos player {} has been found in local network", getUDN());
1000             updateStatus(ThingStatus.ONLINE);
1001         }
1002     }
1003
1004     protected void updateCurrentZoneName() {
1005         Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneAttributes", null);
1006
1007         for (String variable : result.keySet()) {
1008             this.onValueReceived(variable, result.get(variable), "DeviceProperties");
1009         }
1010     }
1011
1012     protected void updateLed() {
1013         Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
1014
1015         for (String variable : result.keySet()) {
1016             this.onValueReceived(variable, result.get(variable), "DeviceProperties");
1017         }
1018     }
1019
1020     protected void updateTime() {
1021         Map<String, String> result = service.invokeAction(this, "AlarmClock", "GetTimeNow", null);
1022
1023         for (String variable : result.keySet()) {
1024             this.onValueReceived(variable, result.get(variable), "AlarmClock");
1025         }
1026     }
1027
1028     protected void updatePosition() {
1029         Map<String, String> result = service.invokeAction(this, "AVTransport", "GetPositionInfo", null);
1030
1031         for (String variable : result.keySet()) {
1032             this.onValueReceived(variable, result.get(variable), "AVTransport");
1033         }
1034     }
1035
1036     protected void updateRunningAlarmProperties() {
1037         Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRunningAlarmProperties", null);
1038
1039         String alarmID = result.get("AlarmID");
1040         String loggedStartTime = result.get("LoggedStartTime");
1041         String newStringValue = null;
1042         if (alarmID != null && loggedStartTime != null) {
1043             newStringValue = alarmID + " - " + loggedStartTime;
1044         } else {
1045             newStringValue = "No running alarm";
1046         }
1047         result.put("RunningAlarmProperties", newStringValue);
1048
1049         for (String variable : result.keySet()) {
1050             this.onValueReceived(variable, result.get(variable), "AVTransport");
1051         }
1052     }
1053
1054     protected boolean updateZoneInfo() {
1055         Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneInfo", null);
1056         for (String variable : result.keySet()) {
1057             this.onValueReceived(variable, result.get(variable), "DeviceProperties");
1058         }
1059
1060         Map<String, String> properties = editProperties();
1061         String value = stateMap.get("HardwareVersion");
1062         if (value != null && !value.isEmpty()) {
1063             properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1064         }
1065         value = stateMap.get("DisplaySoftwareVersion");
1066         if (value != null && !value.isEmpty()) {
1067             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1068         }
1069         value = stateMap.get("SerialNumber");
1070         if (value != null && !value.isEmpty()) {
1071             properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1072         }
1073         value = stateMap.get("MACAddress");
1074         if (value != null && !value.isEmpty()) {
1075             properties.put(MAC_ADDRESS, value);
1076         }
1077         value = stateMap.get("IPAddress");
1078         if (value != null && !value.isEmpty()) {
1079             properties.put(IP_ADDRESS, value);
1080         }
1081         updateProperties(properties);
1082
1083         return !result.isEmpty();
1084     }
1085
1086     public String getCoordinator() {
1087         for (SonosZoneGroup zg : getZoneGroups()) {
1088             if (zg.getMembers().contains(getUDN())) {
1089                 return zg.getCoordinator();
1090             }
1091         }
1092         return getUDN();
1093     }
1094
1095     public boolean isCoordinator() {
1096         return getUDN().equals(getCoordinator());
1097     }
1098
1099     protected void updateMediaInformation() {
1100         String currentURI = getCurrentURI();
1101         SonosMetaData currentTrack = getTrackMetadata();
1102         SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1103
1104         String artist = null;
1105         String album = null;
1106         String title = null;
1107         String resultString = null;
1108         String stationID = null;
1109         boolean needsUpdating = false;
1110
1111         // if currentURI == null, we do nothing
1112         if (currentURI != null) {
1113             if (currentURI.isEmpty()) {
1114                 // Reset data
1115                 needsUpdating = true;
1116             }
1117
1118             // if (currentURI.contains(GROUP_URI)) we do nothing, because
1119             // The Sonos is a slave member of a group
1120             // The media information will be updated by the coordinator
1121             // Notification of group change occurs later, so we just check the URI
1122
1123             else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1124                 // Radio stream (tune-in)
1125                 boolean opmlUrlSucceeded = false;
1126                 stationID = extractStationId(currentURI);
1127                 String url = opmlUrl;
1128                 if (url != null) {
1129                     String mac = getMACAddress();
1130                     if (stationID != null && !stationID.isEmpty() && mac != null && !mac.isEmpty()) {
1131                         url = url.replace("%id", stationID);
1132                         url = url.replace("%serial", mac);
1133
1134                         String response = null;
1135                         try {
1136                             response = HttpUtil.executeUrl("GET", url, SOCKET_TIMEOUT);
1137                         } catch (IOException e) {
1138                             logger.debug("Request to device failed", e);
1139                         }
1140
1141                         if (response != null) {
1142                             List<String> fields = SonosXMLParser.getRadioTimeFromXML(response);
1143
1144                             if (!fields.isEmpty()) {
1145                                 opmlUrlSucceeded = true;
1146
1147                                 resultString = "";
1148                                 for (String field : fields) {
1149                                     if (resultString.isEmpty()) {
1150                                         // radio name should be first field
1151                                         title = field;
1152                                     } else {
1153                                         resultString += " - ";
1154                                     }
1155                                     resultString += field;
1156                                 }
1157
1158                                 needsUpdating = true;
1159                             }
1160                         }
1161                     }
1162                 }
1163                 if (!opmlUrlSucceeded) {
1164                     if (currentUriMetaData != null) {
1165                         title = currentUriMetaData.getTitle();
1166                         if (currentTrack == null || currentTrack.getStreamContent().isEmpty()) {
1167                             resultString = title;
1168                         } else {
1169                             resultString = title + " - " + currentTrack.getStreamContent();
1170                         }
1171                         needsUpdating = true;
1172                     }
1173                 }
1174             }
1175
1176             else if (isPlayingLineIn(currentURI)) {
1177                 if (currentTrack != null) {
1178                     title = currentTrack.getTitle();
1179                     resultString = title;
1180                     needsUpdating = true;
1181                 }
1182             }
1183
1184             else if (isPlayingRadio(currentURI)
1185                     || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1186                 // isPlayingRadio(currentURI) is true for Google Play Music radio or Apple Music radio
1187                 if (currentTrack != null) {
1188                     artist = !currentTrack.getAlbumArtist().isEmpty() ? currentTrack.getAlbumArtist()
1189                             : currentTrack.getCreator();
1190                     album = currentTrack.getAlbum();
1191                     title = currentTrack.getTitle();
1192                     resultString = artist + " - " + album + " - " + title;
1193                     needsUpdating = true;
1194                 }
1195             }
1196         }
1197
1198         String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1199                 ? currentTrack.getAlbumArtUri()
1200                 : "";
1201
1202         ZonePlayerHandler handlerForImageUpdate = null;
1203         for (String member : getZoneGroupMembers()) {
1204             try {
1205                 ZonePlayerHandler memberHandler = getHandlerByName(member);
1206                 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1207                     if (memberHandler.isLinked(CURRENTALBUMART)
1208                             && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1209                         handlerForImageUpdate = memberHandler;
1210                     }
1211                     memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1212                             "AVTransport");
1213                     if (needsUpdating) {
1214                         memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "", "AVTransport");
1215                         memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "", "AVTransport");
1216                         memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "", "AVTransport");
1217                         memberHandler.onValueReceived("CurrentURIFormatted", (resultString != null) ? resultString : "",
1218                                 "AVTransport");
1219                         memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, "AVTransport");
1220                     }
1221                 }
1222             } catch (IllegalStateException e) {
1223                 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1224             }
1225         }
1226         if (needsUpdating && handlerForImageUpdate != null) {
1227             handlerForImageUpdate.updateAlbumArtChannel(true);
1228         }
1229     }
1230
1231     private @Nullable String extractStationId(String uri) {
1232         String stationID = null;
1233         if (isPlayingStream(uri)) {
1234             stationID = substringBetween(uri, ":s", "?sid");
1235         } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1236             stationID = substringBetween(uri, "sid=s", "&");
1237         }
1238         return stationID;
1239     }
1240
1241     private @Nullable String substringBetween(String str, String open, String close) {
1242         String result = null;
1243         int idx1 = str.indexOf(open);
1244         if (idx1 >= 0) {
1245             idx1 += open.length();
1246             int idx2 = str.indexOf(close, idx1);
1247             if (idx2 >= 0) {
1248                 result = str.substring(idx1, idx2);
1249             }
1250         }
1251         return result;
1252     }
1253
1254     public @Nullable String getGroupCoordinatorIsLocal() {
1255         return stateMap.get("GroupCoordinatorIsLocal");
1256     }
1257
1258     public boolean isGroupCoordinator() {
1259         return "true".equals(getGroupCoordinatorIsLocal());
1260     }
1261
1262     @Override
1263     public String getUDN() {
1264         String udn = configuration.udn;
1265         return udn != null && !udn.isEmpty() ? udn : "undefined";
1266     }
1267
1268     public @Nullable String getCurrentURI() {
1269         return stateMap.get("CurrentURI");
1270     }
1271
1272     public @Nullable String getCurrentURIMetadataAsString() {
1273         return stateMap.get("CurrentURIMetaData");
1274     }
1275
1276     public @Nullable SonosMetaData getCurrentURIMetadata() {
1277         String metaData = getCurrentURIMetadataAsString();
1278         return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1279     }
1280
1281     public @Nullable SonosMetaData getTrackMetadata() {
1282         String metaData = stateMap.get("CurrentTrackMetaData");
1283         return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1284     }
1285
1286     public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1287         String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1288         return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1289     }
1290
1291     public @Nullable String getMACAddress() {
1292         String mac = stateMap.get("MACAddress");
1293         if (mac == null || mac.isEmpty()) {
1294             updateZoneInfo();
1295         }
1296         return stateMap.get("MACAddress");
1297     }
1298
1299     public @Nullable String getRefreshedPosition() {
1300         updatePosition();
1301         return stateMap.get("RelTime");
1302     }
1303
1304     public long getRefreshedCurrenTrackNr() {
1305         updatePosition();
1306         String value = stateMap.get("Track");
1307         if (value != null) {
1308             return Long.valueOf(value);
1309         } else {
1310             return -1;
1311         }
1312     }
1313
1314     public @Nullable String getVolume() {
1315         return stateMap.get("VolumeMaster");
1316     }
1317
1318     public @Nullable String getSurroundEnabled() {
1319         return stateMap.get("SurroundEnabled");
1320     }
1321
1322     public @Nullable String getSurroundMusicMode() {
1323         return stateMap.get("SurroundMode");
1324     }
1325
1326     public @Nullable String getSurroundTvLevel() {
1327         return stateMap.get("SurroundLevel");
1328     }
1329
1330     public @Nullable String getSurroundMusicLevel() {
1331         return stateMap.get("MusicSurroundLevel");
1332     }
1333
1334     public @Nullable String getSubwooferEnabled() {
1335         return stateMap.get("SubEnabled");
1336     }
1337
1338     public @Nullable String getSubwooferGain() {
1339         return stateMap.get("SubGain");
1340     }
1341
1342     public @Nullable String getTransportState() {
1343         return stateMap.get("TransportState");
1344     }
1345
1346     public @Nullable String getCurrentTitle() {
1347         return stateMap.get("CurrentTitle");
1348     }
1349
1350     public @Nullable String getCurrentArtist() {
1351         return stateMap.get("CurrentArtist");
1352     }
1353
1354     public @Nullable String getCurrentAlbum() {
1355         return stateMap.get("CurrentAlbum");
1356     }
1357
1358     public List<SonosEntry> getArtists(String filter) {
1359         return getEntries("A:", filter);
1360     }
1361
1362     public List<SonosEntry> getArtists() {
1363         return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1364     }
1365
1366     public List<SonosEntry> getAlbums(String filter) {
1367         return getEntries("A:ALBUM", filter);
1368     }
1369
1370     public List<SonosEntry> getAlbums() {
1371         return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1372     }
1373
1374     public List<SonosEntry> getTracks(String filter) {
1375         return getEntries("A:TRACKS", filter);
1376     }
1377
1378     public List<SonosEntry> getTracks() {
1379         return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1380     }
1381
1382     public List<SonosEntry> getQueue(String filter) {
1383         return getEntries("Q:0", filter);
1384     }
1385
1386     public List<SonosEntry> getQueue() {
1387         return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1388     }
1389
1390     public long getQueueSize() {
1391         return getNbEntries("Q:0");
1392     }
1393
1394     public List<SonosEntry> getPlayLists(String filter) {
1395         return getEntries("SQ:", filter);
1396     }
1397
1398     public List<SonosEntry> getPlayLists() {
1399         return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1400     }
1401
1402     public List<SonosEntry> getFavoriteRadios(String filter) {
1403         return getEntries("R:0/0", filter);
1404     }
1405
1406     public List<SonosEntry> getFavoriteRadios() {
1407         return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1408     }
1409
1410     /**
1411      * Searches for entries in the 'favorites' list on a sonos account
1412      *
1413      * @return
1414      */
1415     public List<SonosEntry> getFavorites() {
1416         return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1417     }
1418
1419     protected List<SonosEntry> getEntries(String type, String filter) {
1420         long startAt = 0;
1421
1422         Map<String, String> inputs = new HashMap<>();
1423         inputs.put("ObjectID", type);
1424         inputs.put("BrowseFlag", "BrowseDirectChildren");
1425         inputs.put("Filter", filter);
1426         inputs.put("StartingIndex", Long.toString(startAt));
1427         inputs.put("RequestedCount", Integer.toString(200));
1428         inputs.put("SortCriteria", "");
1429
1430         Map<String, String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1431
1432         String initialResult = result.get("Result");
1433         if (initialResult == null) {
1434             return Collections.emptyList();
1435         }
1436
1437         long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1438         long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1439
1440         List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1441         startAt = startAt + initialNumberReturned;
1442
1443         while (startAt < totalMatches) {
1444             inputs.put("StartingIndex", Long.toString(startAt));
1445             result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1446
1447             // Execute this action synchronously
1448             String nextResult = result.get("Result");
1449             if (nextResult == null) {
1450                 break;
1451             }
1452
1453             long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1454
1455             resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1456
1457             startAt = startAt + numberReturned;
1458         }
1459
1460         return resultList;
1461     }
1462
1463     protected long getNbEntries(String type) {
1464         Map<String, String> inputs = new HashMap<>();
1465         inputs.put("ObjectID", type);
1466         inputs.put("BrowseFlag", "BrowseDirectChildren");
1467         inputs.put("Filter", "dc:title");
1468         inputs.put("StartingIndex", "0");
1469         inputs.put("RequestedCount", "1");
1470         inputs.put("SortCriteria", "");
1471
1472         Map<String, String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1473
1474         return getResultEntry(result, "TotalMatches", type, "dc:title");
1475     }
1476
1477     /**
1478      * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1479      *
1480      * @param resultInput - the map to be examined for the requestedKey
1481      * @param requestedKey - the key to be sought in the resultInput map
1482      * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1483      * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1484      *
1485      * @return 0 as long or the value corresponding to the requiredKey if found
1486      */
1487     private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1488             String entriesFilter) {
1489         long result = 0;
1490
1491         if (resultInput.isEmpty()) {
1492             return result;
1493         }
1494
1495         try {
1496             String resultString = resultInput.get(requestedKey);
1497             if (resultString == null) {
1498                 throw new NumberFormatException("Requested key is null.");
1499             }
1500             result = Long.valueOf(resultString);
1501         } catch (NumberFormatException ex) {
1502             logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1503                     requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1504         }
1505
1506         return result;
1507     }
1508
1509     /**
1510      * Save the state (track, position etc) of the Sonos Zone player.
1511      *
1512      * @return true if no error occurred.
1513      */
1514     protected void saveState() {
1515         synchronized (stateLock) {
1516             savedState = new SonosZonePlayerState();
1517             String currentURI = getCurrentURI();
1518
1519             savedState.transportState = getTransportState();
1520             savedState.volume = getVolume();
1521
1522             if (currentURI != null) {
1523                 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
1524                         || isPlayingRadio(currentURI)) {
1525                     // we are streaming music, like tune-in radio or Google Play Music radio
1526                     SonosMetaData track = getTrackMetadata();
1527                     SonosMetaData current = getCurrentURIMetadata();
1528                     if (track != null && current != null) {
1529                         savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1530                                 current.getUpnpClass(), currentURI);
1531                     }
1532                 } else if (currentURI.contains(GROUP_URI)) {
1533                     // we are a slave to some coordinator
1534                     savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1535                 } else if (isPlayingLineIn(currentURI)) {
1536                     // we are streaming from the Line In connection
1537                     savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1538                 } else if (isPlayingQueue(currentURI)) {
1539                     // we are playing something that sits in the queue
1540                     SonosMetaData queued = getEnqueuedTransportURIMetaData();
1541                     if (queued != null) {
1542                         savedState.track = getRefreshedCurrenTrackNr();
1543
1544                         if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1545                             // we are playing a real 'saved' playlist
1546                             List<SonosEntry> playLists = getPlayLists();
1547                             for (SonosEntry someList : playLists) {
1548                                 if (someList.getTitle().equals(queued.getTitle())) {
1549                                     savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1550                                             someList.getParentId(), "", "", "", someList.getUpnpClass(),
1551                                             someList.getRes());
1552                                     break;
1553                                 }
1554                             }
1555                         } else if (queued.getUpnpClass().contains("object.container")) {
1556                             // we are playing some other sort of
1557                             // 'container' - we will save that to a
1558                             // playlist for our convenience
1559                             logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1560
1561                             // save the playlist
1562                             String existingList = "";
1563                             List<SonosEntry> playLists = getPlayLists();
1564                             for (SonosEntry someList : playLists) {
1565                                 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1566                                     existingList = someList.getId();
1567                                     break;
1568                                 }
1569                             }
1570
1571                             saveQueue(TITLE_PREFIX + getUDN(), existingList);
1572
1573                             // get all the playlists and a ref to our
1574                             // saved list
1575                             playLists = getPlayLists();
1576                             for (SonosEntry someList : playLists) {
1577                                 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1578                                     savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1579                                             someList.getParentId(), "", "", "", someList.getUpnpClass(),
1580                                             someList.getRes());
1581                                     break;
1582                                 }
1583                             }
1584                         }
1585                     } else {
1586                         savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1587                     }
1588                 }
1589
1590                 savedState.relTime = getRefreshedPosition();
1591             } else {
1592                 savedState.entry = null;
1593             }
1594         }
1595     }
1596
1597     /**
1598      * Restore the state (track, position etc) of the Sonos Zone player.
1599      *
1600      * @return true if no error occurred.
1601      */
1602     protected void restoreState() {
1603         synchronized (stateLock) {
1604             SonosZonePlayerState state = savedState;
1605             if (state != null) {
1606                 // put settings back
1607                 String volume = state.volume;
1608                 if (volume != null) {
1609                     setVolume(DecimalType.valueOf(volume));
1610                 }
1611
1612                 if (isCoordinator()) {
1613                     SonosEntry entry = state.entry;
1614                     if (entry != null) {
1615                         // check if we have a playlist to deal with
1616                         if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1617                             addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1618                             entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1619                             setCurrentURI(entry);
1620                             setPositionTrack(state.track);
1621                         } else {
1622                             setCurrentURI(entry);
1623                             setPosition(state.relTime);
1624                         }
1625                     }
1626
1627                     String transportState = state.transportState;
1628                     if (transportState != null) {
1629                         if (transportState.equals(STATE_PLAYING)) {
1630                             play();
1631                         } else if (transportState.equals(STATE_STOPPED)) {
1632                             stop();
1633                         } else if (transportState.equals(STATE_PAUSED_PLAYBACK)) {
1634                             pause();
1635                         }
1636                     }
1637                 }
1638             }
1639         }
1640     }
1641
1642     public void saveQueue(String name, String queueID) {
1643         Map<String, String> inputs = new HashMap<>();
1644         inputs.put("Title", name);
1645         inputs.put("ObjectID", queueID);
1646
1647         Map<String, String> result = service.invokeAction(this, "AVTransport", "SaveQueue", inputs);
1648
1649         for (String variable : result.keySet()) {
1650             this.onValueReceived(variable, result.get(variable), "AVTransport");
1651         }
1652     }
1653
1654     public void setVolume(Command command) {
1655         if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1656                 || command instanceof PercentType) {
1657             Map<String, String> inputs = new HashMap<>();
1658
1659             String newValue = null;
1660             String currentVolume = getVolume();
1661             if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1662                 int i = Integer.valueOf(currentVolume);
1663                 newValue = String.valueOf(Math.min(100, i + 1));
1664             } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1665                 int i = Integer.valueOf(currentVolume);
1666                 newValue = String.valueOf(Math.max(0, i - 1));
1667             } else if (command == OnOffType.ON) {
1668                 newValue = "100";
1669             } else if (command == OnOffType.OFF) {
1670                 newValue = "0";
1671             } else if (command instanceof DecimalType) {
1672                 newValue = String.valueOf(((DecimalType) command).intValue());
1673             } else {
1674                 return;
1675             }
1676             inputs.put("Channel", "Master");
1677             inputs.put("DesiredVolume", newValue);
1678
1679             Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetVolume", inputs);
1680
1681             for (String variable : result.keySet()) {
1682                 this.onValueReceived(variable, result.get(variable), "RenderingControl");
1683             }
1684         }
1685     }
1686
1687     /**
1688      * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1689      * AdHoc groups handles the volume specifically for each player.
1690      * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1691      */
1692     public void setVolumeForGroup(Command command) {
1693         if (isAdHocGroup() || isStandalonePlayer()) {
1694             setVolume(command);
1695         } else {
1696             try {
1697                 getCoordinatorHandler().setVolume(command);
1698             } catch (IllegalStateException e) {
1699                 logger.debug("Cannot set group volume ({})", e.getMessage());
1700             }
1701         }
1702     }
1703
1704     /**
1705      * Checks if the player receiving the command is part of a group that
1706      * consists of randomly added players or contains bonded players
1707      *
1708      * @return boolean
1709      */
1710     private boolean isAdHocGroup() {
1711         SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1712         if (currentZoneGroup != null) {
1713             List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1714
1715             for (String zoneName : zoneGroupMemberNames) {
1716                 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1717                     // At least one "ZoneName" differs so we have an AdHoc group
1718                     return true;
1719                 }
1720             }
1721         }
1722         return false;
1723     }
1724
1725     /**
1726      * Checks if the player receiving the command is a standalone player
1727      *
1728      * @return boolean
1729      */
1730     private boolean isStandalonePlayer() {
1731         SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1732         return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1733     }
1734
1735     private Collection<SonosZoneGroup> getZoneGroups() {
1736         String zoneGroupState = stateMap.get("ZoneGroupState");
1737         return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1738     }
1739
1740     /**
1741      * Returns the current zone group
1742      * (of which the player receiving the command is part)
1743      *
1744      * @return {@link SonosZoneGroup}
1745      */
1746     private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1747         for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1748             if (zoneGroup.getMembers().contains(getUDN())) {
1749                 return zoneGroup;
1750             }
1751         }
1752         logger.debug("Could not fetch Sonos group state information");
1753         return null;
1754     }
1755
1756     /**
1757      * Sets the volume level for a notification sound
1758      *
1759      * @param notificationSoundVolume
1760      */
1761     public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1762         if (notificationSoundVolume != null) {
1763             setVolumeForGroup(notificationSoundVolume);
1764         }
1765     }
1766
1767     /**
1768      * Gets the volume level for a notification sound
1769      */
1770     public @Nullable PercentType getNotificationSoundVolume() {
1771         Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1772         if (notificationSoundVolume == null) {
1773             // if no value is set we use the current volume instead
1774             String volume = getVolume();
1775             return volume != null ? new PercentType(volume) : null;
1776         }
1777         return new PercentType(notificationSoundVolume);
1778     }
1779
1780     public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1781         Map<String, String> inputs = new HashMap<>();
1782
1783         try {
1784             inputs.put("InstanceID", "0");
1785             inputs.put("EnqueuedURI", URI);
1786             inputs.put("EnqueuedURIMetaData", meta);
1787             inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1788             inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1789         } catch (NumberFormatException ex) {
1790             logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1791         }
1792
1793         Map<String, String> result = service.invokeAction(this, "AVTransport", "AddURIToQueue", inputs);
1794
1795         for (String variable : result.keySet()) {
1796             this.onValueReceived(variable, result.get(variable), "AVTransport");
1797         }
1798     }
1799
1800     public void setCurrentURI(SonosEntry newEntry) {
1801         setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1802     }
1803
1804     public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1805         if (URI != null && URIMetaData != null) {
1806             logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1807             Map<String, String> inputs = new HashMap<>();
1808
1809             try {
1810                 inputs.put("InstanceID", "0");
1811                 inputs.put("CurrentURI", URI);
1812                 inputs.put("CurrentURIMetaData", URIMetaData);
1813             } catch (NumberFormatException ex) {
1814                 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1815             }
1816
1817             Map<String, String> result = service.invokeAction(this, "AVTransport", "SetAVTransportURI", inputs);
1818
1819             for (String variable : result.keySet()) {
1820                 this.onValueReceived(variable, result.get(variable), "AVTransport");
1821             }
1822         }
1823     }
1824
1825     public void setPosition(@Nullable String relTime) {
1826         seek("REL_TIME", relTime);
1827     }
1828
1829     public void setPositionTrack(long tracknr) {
1830         seek("TRACK_NR", Long.toString(tracknr));
1831     }
1832
1833     public void setPositionTrack(String tracknr) {
1834         seek("TRACK_NR", tracknr);
1835     }
1836
1837     protected void seek(String unit, @Nullable String target) {
1838         if (target != null) {
1839             Map<String, String> inputs = new HashMap<>();
1840
1841             try {
1842                 inputs.put("InstanceID", "0");
1843                 inputs.put("Unit", unit);
1844                 inputs.put("Target", target);
1845             } catch (NumberFormatException ex) {
1846                 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1847             }
1848
1849             Map<String, String> result = service.invokeAction(this, "AVTransport", "Seek", inputs);
1850
1851             for (String variable : result.keySet()) {
1852                 this.onValueReceived(variable, result.get(variable), "AVTransport");
1853             }
1854         }
1855     }
1856
1857     public void play() {
1858         Map<String, String> inputs = new HashMap<>();
1859         inputs.put("Speed", "1");
1860
1861         Map<String, String> result = service.invokeAction(this, "AVTransport", "Play", inputs);
1862
1863         for (String variable : result.keySet()) {
1864             this.onValueReceived(variable, result.get(variable), "AVTransport");
1865         }
1866     }
1867
1868     public void stop() {
1869         Map<String, String> result = service.invokeAction(this, "AVTransport", "Stop", null);
1870
1871         for (String variable : result.keySet()) {
1872             this.onValueReceived(variable, result.get(variable), "AVTransport");
1873         }
1874     }
1875
1876     public void pause() {
1877         Map<String, String> result = service.invokeAction(this, "AVTransport", "Pause", null);
1878
1879         for (String variable : result.keySet()) {
1880             this.onValueReceived(variable, result.get(variable), "AVTransport");
1881         }
1882     }
1883
1884     public void setShuffle(Command command) {
1885         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1886             try {
1887                 ZonePlayerHandler coordinator = getCoordinatorHandler();
1888
1889                 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1890                         || command.equals(OpenClosedType.OPEN)) {
1891                     switch (coordinator.getRepeatMode()) {
1892                         case "ALL":
1893                             coordinator.updatePlayMode("SHUFFLE");
1894                             break;
1895                         case "ONE":
1896                             coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
1897                             break;
1898                         case "OFF":
1899                             coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
1900                             break;
1901                     }
1902                 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
1903                         || command.equals(OpenClosedType.CLOSED)) {
1904                     switch (coordinator.getRepeatMode()) {
1905                         case "ALL":
1906                             coordinator.updatePlayMode("REPEAT_ALL");
1907                             break;
1908                         case "ONE":
1909                             coordinator.updatePlayMode("REPEAT_ONE");
1910                             break;
1911                         case "OFF":
1912                             coordinator.updatePlayMode("NORMAL");
1913                             break;
1914                     }
1915                 }
1916             } catch (IllegalStateException e) {
1917                 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
1918             }
1919         }
1920     }
1921
1922     public void setRepeat(Command command) {
1923         if (command instanceof StringType) {
1924             try {
1925                 ZonePlayerHandler coordinator = getCoordinatorHandler();
1926
1927                 switch (command.toString()) {
1928                     case "ALL":
1929                         coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
1930                         break;
1931                     case "ONE":
1932                         coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
1933                         break;
1934                     case "OFF":
1935                         coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
1936                         break;
1937                     default:
1938                         logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
1939                                 command.toString());
1940                         break;
1941                 }
1942             } catch (IllegalStateException e) {
1943                 logger.debug("Cannot handle repeat command ({})", e.getMessage());
1944             }
1945         }
1946     }
1947
1948     public void setSubwoofer(Command command) {
1949         setEqualizerBooleanSetting(command, "SubEnabled");
1950     }
1951
1952     public void setSubwooferGain(Command command) {
1953         setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
1954     }
1955
1956     public void setSurround(Command command) {
1957         setEqualizerBooleanSetting(command, "SurroundEnabled");
1958     }
1959
1960     public void setSurroundMusicMode(Command command) {
1961         if (command instanceof StringType) {
1962             setEQ("SurroundMode", command.toString());
1963         }
1964     }
1965
1966     public void setSurroundMusicLevel(Command command) {
1967         setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
1968                 MAX_SURROUND_LEVEL);
1969     }
1970
1971     public void setSurroundTvLevel(Command command) {
1972         setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
1973                 MAX_SURROUND_LEVEL);
1974     }
1975
1976     public void setNightMode(Command command) {
1977         setEqualizerBooleanSetting(command, "NightMode");
1978     }
1979
1980     public void setSpeechEnhancement(Command command) {
1981         setEqualizerBooleanSetting(command, "DialogLevel");
1982     }
1983
1984     private void setEqualizerBooleanSetting(Command command, String eqType) {
1985         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1986             setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1987                     || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
1988         }
1989     }
1990
1991     private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
1992             int maxValue) {
1993         if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1994             String newValue = null;
1995             if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1996                 int i = Integer.valueOf(currentValue);
1997                 newValue = String.valueOf(Math.min(maxValue, i + 1));
1998             } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1999                 int i = Integer.valueOf(currentValue);
2000                 newValue = String.valueOf(Math.max(minValue, i - 1));
2001             } else if (command instanceof DecimalType) {
2002                 newValue = String.valueOf(((DecimalType) command).intValue());
2003             } else {
2004                 return;
2005             }
2006             setEQ(eqType, newValue);
2007         }
2008     }
2009
2010     private void setEQ(String eqType, String value) {
2011         try {
2012             Map<String, String> inputs = new HashMap<>();
2013             inputs.put("InstanceID", "0");
2014             inputs.put("EQType", eqType);
2015             inputs.put("DesiredValue", value);
2016             Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetEQ", inputs);
2017
2018             for (String variable : result.keySet()) {
2019                 this.onValueReceived(variable, result.get(variable), "RenderingControl");
2020             }
2021         } catch (IllegalStateException e) {
2022             logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2023         }
2024     }
2025
2026     public @Nullable String getNightMode() {
2027         return stateMap.get("NightMode");
2028     }
2029
2030     public boolean isNightModeOn() {
2031         return "1".equals(getNightMode());
2032     }
2033
2034     public @Nullable String getDialogLevel() {
2035         return stateMap.get("DialogLevel");
2036     }
2037
2038     public boolean isSpeechEnhanced() {
2039         return "1".equals(getDialogLevel());
2040     }
2041
2042     public @Nullable String getPlayMode() {
2043         return stateMap.get("CurrentPlayMode");
2044     }
2045
2046     public Boolean isShuffleActive() {
2047         String playMode = getPlayMode();
2048         return (playMode != null && playMode.startsWith("SHUFFLE"));
2049     }
2050
2051     public String getRepeatMode() {
2052         String mode = "OFF";
2053         String playMode = getPlayMode();
2054         if (playMode != null) {
2055             switch (playMode) {
2056                 case "REPEAT_ALL":
2057                 case "SHUFFLE":
2058                     mode = "ALL";
2059                     break;
2060                 case "REPEAT_ONE":
2061                 case "SHUFFLE_REPEAT_ONE":
2062                     mode = "ONE";
2063                     break;
2064                 case "NORMAL":
2065                 case "SHUFFLE_NOREPEAT":
2066                 default:
2067                     mode = "OFF";
2068                     break;
2069             }
2070         }
2071         return mode;
2072     }
2073
2074     protected void updatePlayMode(String playMode) {
2075         Map<String, String> inputs = new HashMap<>();
2076         inputs.put("InstanceID", "0");
2077         inputs.put("NewPlayMode", playMode);
2078
2079         Map<String, String> result = service.invokeAction(this, "AVTransport", "SetPlayMode", inputs);
2080
2081         for (String variable : result.keySet()) {
2082             this.onValueReceived(variable, result.get(variable), "AVTransport");
2083         }
2084     }
2085
2086     /**
2087      * Clear all scheduled music from the current queue.
2088      *
2089      */
2090     public void removeAllTracksFromQueue() {
2091         Map<String, String> inputs = new HashMap<>();
2092         inputs.put("InstanceID", "0");
2093
2094         Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveAllTracksFromQueue", inputs);
2095
2096         for (String variable : result.keySet()) {
2097             this.onValueReceived(variable, result.get(variable), "AVTransport");
2098         }
2099     }
2100
2101     /**
2102      * Play music from the line-in of the given Player referenced by the given UDN or name
2103      *
2104      * @param udn or name
2105      */
2106     public void playLineIn(Command command) {
2107         if (command instanceof StringType) {
2108             try {
2109                 LineInType lineInType = LineInType.ANY;
2110                 String remotePlayerName = command.toString();
2111                 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2112                     lineInType = LineInType.ANALOG;
2113                     remotePlayerName = remotePlayerName.substring(7);
2114                 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2115                     lineInType = LineInType.DIGITAL;
2116                     remotePlayerName = remotePlayerName.substring(8);
2117                 }
2118                 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2119                 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2120
2121                 // check if player has a line-in connected
2122                 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2123                         || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2124                     // stop whatever is currently playing
2125                     coordinatorHandler.stop();
2126
2127                     // set the URI
2128                     if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2129                         coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2130                     } else {
2131                         coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2132                     }
2133
2134                     // take the system off mute
2135                     coordinatorHandler.setMute(OnOffType.OFF);
2136
2137                     // start jammin'
2138                     coordinatorHandler.play();
2139                 } else {
2140                     logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2141                 }
2142             } catch (IllegalStateException e) {
2143                 logger.debug("Cannot play line-in ({})", e.getMessage());
2144             }
2145         }
2146     }
2147
2148     private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2149         ZonePlayerHandler handler = coordinatorHandler;
2150         if (handler != null) {
2151             return handler;
2152         }
2153         try {
2154             handler = getHandlerByName(getCoordinator());
2155             coordinatorHandler = handler;
2156             return handler;
2157         } catch (IllegalStateException e) {
2158             throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2159         }
2160     }
2161
2162     /**
2163      * Returns a list of all zone group members this particular player is member of
2164      * Or empty list if the players is not assigned to any group
2165      *
2166      * @return a list of Strings containing the UDNs of other group members
2167      */
2168     protected List<String> getZoneGroupMembers() {
2169         List<String> result = new ArrayList<>();
2170
2171         Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2172         if (!zoneGroups.isEmpty()) {
2173             for (SonosZoneGroup zg : zoneGroups) {
2174                 if (zg.getMembers().contains(getUDN())) {
2175                     result.addAll(zg.getMembers());
2176                     break;
2177                 }
2178             }
2179         } else {
2180             // If the group topology was not yet received, return at least the current Sonos zone
2181             result.add(getUDN());
2182         }
2183         return result;
2184     }
2185
2186     /**
2187      * Returns a list of other zone group members this particular player is member of
2188      * Or empty list if the players is not assigned to any group
2189      *
2190      * @return a list of Strings containing the UDNs of other group members
2191      */
2192     protected List<String> getOtherZoneGroupMembers() {
2193         List<String> zoneGroupMembers = getZoneGroupMembers();
2194         zoneGroupMembers.remove(getUDN());
2195         return zoneGroupMembers;
2196     }
2197
2198     protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2199         for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2200             Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2201             if (thing != null) {
2202                 ThingHandler handler = thing.getHandler();
2203                 if (handler instanceof ZonePlayerHandler) {
2204                     return (ZonePlayerHandler) handler;
2205                 }
2206             }
2207         }
2208         for (Thing aThing : localThingRegistry.getAll()) {
2209             if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2210                     && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2211                 ThingHandler handler = aThing.getHandler();
2212                 if (handler instanceof ZonePlayerHandler) {
2213                     return (ZonePlayerHandler) handler;
2214                 }
2215             }
2216         }
2217         throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2218     }
2219
2220     public void setMute(Command command) {
2221         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2222             Map<String, String> inputs = new HashMap<>();
2223             inputs.put("Channel", "Master");
2224
2225             if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2226                 inputs.put("DesiredMute", "True");
2227             } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2228                     || command.equals(OpenClosedType.CLOSED)) {
2229                 inputs.put("DesiredMute", "False");
2230             }
2231
2232             Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetMute", inputs);
2233
2234             for (String variable : result.keySet()) {
2235                 this.onValueReceived(variable, result.get(variable), "RenderingControl");
2236             }
2237         }
2238     }
2239
2240     public List<SonosAlarm> getCurrentAlarmList() {
2241         Map<String, String> result = service.invokeAction(this, "AlarmClock", "ListAlarms", null);
2242
2243         for (String variable : result.keySet()) {
2244             this.onValueReceived(variable, result.get(variable), "AlarmClock");
2245         }
2246
2247         String alarmList = result.get("CurrentAlarmList");
2248         return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2249     }
2250
2251     public void updateAlarm(SonosAlarm alarm) {
2252         Map<String, String> inputs = new HashMap<>();
2253
2254         try {
2255             inputs.put("ID", Integer.toString(alarm.getId()));
2256             inputs.put("StartLocalTime", alarm.getStartTime());
2257             inputs.put("Duration", alarm.getDuration());
2258             inputs.put("Recurrence", alarm.getRecurrence());
2259             inputs.put("RoomUUID", alarm.getRoomUUID());
2260             inputs.put("ProgramURI", alarm.getProgramURI());
2261             inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2262             inputs.put("PlayMode", alarm.getPlayMode());
2263             inputs.put("Volume", Integer.toString(alarm.getVolume()));
2264             if (alarm.getIncludeLinkedZones()) {
2265                 inputs.put("IncludeLinkedZones", "1");
2266             } else {
2267                 inputs.put("IncludeLinkedZones", "0");
2268             }
2269
2270             if (alarm.getEnabled()) {
2271                 inputs.put("Enabled", "1");
2272             } else {
2273                 inputs.put("Enabled", "0");
2274             }
2275         } catch (NumberFormatException ex) {
2276             logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2277         }
2278
2279         Map<String, String> result = service.invokeAction(this, "AlarmClock", "UpdateAlarm", inputs);
2280
2281         for (String variable : result.keySet()) {
2282             this.onValueReceived(variable, result.get(variable), "AlarmClock");
2283         }
2284     }
2285
2286     public void setAlarm(Command command) {
2287         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2288             if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2289                 setAlarm(true);
2290             } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2291                     || command.equals(OpenClosedType.CLOSED)) {
2292                 setAlarm(false);
2293             }
2294         }
2295     }
2296
2297     public void setAlarm(boolean alarmSwitch) {
2298         List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2299
2300         // find the nearest alarm - take the current time from the Sonos system,
2301         // not the system where we are running
2302         SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2303         fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2304
2305         String currentLocalTime = getTime();
2306         Date currentDateTime = null;
2307         try {
2308             currentDateTime = fmt.parse(currentLocalTime);
2309         } catch (ParseException e) {
2310             logger.debug("An exception occurred while formatting a date", e);
2311         }
2312
2313         if (currentDateTime != null) {
2314             Calendar currentDateTimeCalendar = Calendar.getInstance();
2315             currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2316             currentDateTimeCalendar.setTime(currentDateTime);
2317             currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2318             long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2319
2320             SonosAlarm firstAlarm = null;
2321
2322             for (SonosAlarm anAlarm : sonosAlarms) {
2323                 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2324                 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2325                 Date durationDate;
2326                 try {
2327                     durationDate = durationFormat.parse(anAlarm.getDuration());
2328                 } catch (ParseException e) {
2329                     logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2330                     continue;
2331                 }
2332
2333                 long duration = durationDate.getTime();
2334
2335                 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2336                     shortestDuration = duration;
2337                     firstAlarm = anAlarm;
2338                 }
2339             }
2340
2341             // Set the Alarm
2342             if (firstAlarm != null) {
2343                 if (alarmSwitch) {
2344                     firstAlarm.setEnabled(true);
2345                 } else {
2346                     firstAlarm.setEnabled(false);
2347                 }
2348
2349                 updateAlarm(firstAlarm);
2350             }
2351         }
2352     }
2353
2354     public @Nullable String getTime() {
2355         updateTime();
2356         return stateMap.get("CurrentLocalTime");
2357     }
2358
2359     public @Nullable String getAlarmRunning() {
2360         return stateMap.get("AlarmRunning");
2361     }
2362
2363     public boolean isAlarmRunning() {
2364         return "1".equals(getAlarmRunning());
2365     }
2366
2367     public void snoozeAlarm(Command command) {
2368         if (isAlarmRunning() && command instanceof DecimalType) {
2369             int minutes = ((DecimalType) command).intValue();
2370
2371             Map<String, String> inputs = new HashMap<>();
2372
2373             Calendar snoozePeriod = Calendar.getInstance();
2374             snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2375             snoozePeriod.setTimeInMillis(0);
2376             snoozePeriod.add(Calendar.MINUTE, minutes);
2377             SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2378             pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2379
2380             try {
2381                 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2382             } catch (NumberFormatException ex) {
2383                 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2384             }
2385
2386             Map<String, String> result = service.invokeAction(this, "AVTransport", "SnoozeAlarm", inputs);
2387
2388             for (String variable : result.keySet()) {
2389                 this.onValueReceived(variable, result.get(variable), "AVTransport");
2390             }
2391         } else {
2392             logger.debug("There is no alarm running on {}", getUDN());
2393         }
2394     }
2395
2396     public @Nullable String getAnalogLineInConnected() {
2397         return stateMap.get(LINEINCONNECTED);
2398     }
2399
2400     public boolean isAnalogLineInConnected() {
2401         return "true".equals(getAnalogLineInConnected());
2402     }
2403
2404     public @Nullable String getOpticalLineInConnected() {
2405         return stateMap.get(TOSLINEINCONNECTED);
2406     }
2407
2408     public boolean isOpticalLineInConnected() {
2409         return "true".equals(getOpticalLineInConnected());
2410     }
2411
2412     public void becomeStandAlonePlayer() {
2413         Map<String, String> result = service.invokeAction(this, "AVTransport", "BecomeCoordinatorOfStandaloneGroup",
2414                 null);
2415
2416         for (String variable : result.keySet()) {
2417             this.onValueReceived(variable, result.get(variable), "AVTransport");
2418         }
2419     }
2420
2421     public void addMember(Command command) {
2422         if (command instanceof StringType) {
2423             SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2424             try {
2425                 getHandlerByName(command.toString()).setCurrentURI(entry);
2426             } catch (IllegalStateException e) {
2427                 logger.debug("Cannot add group member ({})", e.getMessage());
2428             }
2429         }
2430     }
2431
2432     public boolean publicAddress(LineInType lineInType) {
2433         // check if sourcePlayer has a line-in connected
2434         if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2435                 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2436             // first remove this player from its own group if any
2437             becomeStandAlonePlayer();
2438
2439             // add all other players to this new group
2440             for (SonosZoneGroup group : getZoneGroups()) {
2441                 for (String player : group.getMembers()) {
2442                     try {
2443                         ZonePlayerHandler somePlayer = getHandlerByName(player);
2444                         if (somePlayer != this) {
2445                             somePlayer.becomeStandAlonePlayer();
2446                             somePlayer.stop();
2447                             addMember(StringType.valueOf(somePlayer.getUDN()));
2448                         }
2449                     } catch (IllegalStateException e) {
2450                         logger.debug("Cannot add to group ({})", e.getMessage());
2451                     }
2452                 }
2453             }
2454
2455             try {
2456                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2457                 // set the URI of the group to the line-in
2458                 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2459                 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2460                     entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2461                 }
2462                 coordinator.setCurrentURI(entry);
2463                 coordinator.play();
2464
2465                 return true;
2466             } catch (IllegalStateException e) {
2467                 logger.debug("Cannot handle command ({})", e.getMessage());
2468                 return false;
2469             }
2470         } else {
2471             logger.debug("Line-in of {} is not connected", getUDN());
2472             return false;
2473         }
2474     }
2475
2476     /**
2477      * Play a given url to music in one of the music libraries.
2478      *
2479      * @param url
2480      *            in the format of //host/folder/filename.mp3
2481      */
2482     public void playURI(Command command) {
2483         if (command instanceof StringType) {
2484             try {
2485                 String url = command.toString();
2486
2487                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2488
2489                 // stop whatever is currently playing
2490                 coordinator.stop();
2491                 coordinator.waitForNotTransportState(STATE_PLAYING);
2492
2493                 // clear any tracks which are pending in the queue
2494                 coordinator.removeAllTracksFromQueue();
2495
2496                 // add the new track we want to play to the queue
2497                 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2498                 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2499                     // default to file based url
2500                     url = FILE_URI + url;
2501                 }
2502                 coordinator.addURIToQueue(url, "", 0, true);
2503
2504                 // set the current playlist to our new queue
2505                 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2506
2507                 // take the system off mute
2508                 coordinator.setMute(OnOffType.OFF);
2509
2510                 // start jammin'
2511                 coordinator.play();
2512             } catch (IllegalStateException e) {
2513                 logger.debug("Cannot play URI ({})", e.getMessage());
2514             }
2515         }
2516     }
2517
2518     private void scheduleNotificationSound(final Command command) {
2519         scheduler.submit(() -> {
2520             synchronized (notificationLock) {
2521                 playNotificationSoundURI(command);
2522             }
2523         });
2524     }
2525
2526     /**
2527      * Play a given notification sound
2528      *
2529      * @param url in the format of //host/folder/filename.mp3
2530      */
2531     public void playNotificationSoundURI(Command notificationURL) {
2532         if (notificationURL instanceof StringType) {
2533             try {
2534                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2535
2536                 String currentURI = coordinator.getCurrentURI();
2537                 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2538                         coordinator.getCurrentURIMetadataAsString());
2539
2540                 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2541                         || isPlayingRadio(currentURI)) {
2542                     handleRadioStream(currentURI, notificationURL, coordinator);
2543                 } else if (isPlayingLineIn(currentURI)) {
2544                     handleLineIn(currentURI, notificationURL, coordinator);
2545                 } else if (isPlayingQueue(currentURI)) {
2546                     handleSharedQueue(currentURI, notificationURL, coordinator);
2547                 } else if (isPlaylistEmpty(coordinator)) {
2548                     handleEmptyQueue(notificationURL, coordinator);
2549                 }
2550                 synchronized (notificationLock) {
2551                     notificationLock.notify();
2552                 }
2553             } catch (IllegalStateException e) {
2554                 logger.debug("Cannot play sound ({})", e.getMessage());
2555             }
2556         }
2557     }
2558
2559     private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2560         return coordinator.getQueueSize() == 0;
2561     }
2562
2563     private boolean isPlayingQueue(@Nullable String currentURI) {
2564         return currentURI != null && currentURI.contains(QUEUE_URI);
2565     }
2566
2567     private boolean isPlayingStream(@Nullable String currentURI) {
2568         return currentURI != null && currentURI.contains(STREAM_URI);
2569     }
2570
2571     private boolean isPlayingRadio(@Nullable String currentURI) {
2572         return currentURI != null && currentURI.contains(RADIO_URI);
2573     }
2574
2575     private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2576         return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2577     }
2578
2579     private boolean isPlayingLineIn(@Nullable String currentURI) {
2580         return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2581     }
2582
2583     private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2584         return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2585     }
2586
2587     private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2588         return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2589     }
2590
2591     /**
2592      * Does a chain of predefined actions when a Notification sound is played by
2593      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2594      * radio streaming is currently loaded
2595      *
2596      * @param currentStreamURI - the currently loaded stream's URI
2597      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2598      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2599      */
2600     private void handleRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2601             ZonePlayerHandler coordinator) {
2602         String nextAction = coordinator.getTransportState();
2603         SonosMetaData track = coordinator.getTrackMetadata();
2604         SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2605
2606         handleNotificationSound(notificationURL, coordinator);
2607         if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2608             coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2609                     "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2610             restoreLastTransportState(coordinator, nextAction);
2611         }
2612     }
2613
2614     /**
2615      * Does a chain of predefined actions when a Notification sound is played by
2616      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2617      * line in is currently loaded
2618      *
2619      * @param currentLineInURI - the currently loaded line-in URI
2620      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2621      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2622      */
2623     private void handleLineIn(@Nullable String currentLineInURI, Command notificationURL,
2624             ZonePlayerHandler coordinator) {
2625         logger.debug("Handling notification while sound from line-in was being played");
2626         String nextAction = coordinator.getTransportState();
2627
2628         handleNotificationSound(notificationURL, coordinator);
2629         if (currentLineInURI != null) {
2630             logger.debug("Restoring sound from line-in using {}", currentLineInURI);
2631             coordinator.setCurrentURI(currentLineInURI, "");
2632             restoreLastTransportState(coordinator, nextAction);
2633         }
2634     }
2635
2636     /**
2637      * Does a chain of predefined actions when a Notification sound is played by
2638      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2639      * shared queue is currently loaded
2640      *
2641      * @param currentQueueURI - the currently loaded queue URI
2642      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2643      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2644      */
2645     private void handleSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2646             ZonePlayerHandler coordinator) {
2647         String nextAction = coordinator.getTransportState();
2648         String trackPosition = coordinator.getRefreshedPosition();
2649         long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2650         logger.debug("handleSharedQueue: currentQueueURI {} trackPosition {} currentTrackNumber {}", currentQueueURI,
2651                 trackPosition, currentTrackNumber);
2652
2653         handleNotificationSound(notificationURL, coordinator);
2654         String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2655         if (queueUri.equals(currentQueueURI)) {
2656             coordinator.setPositionTrack(currentTrackNumber);
2657             coordinator.setPosition(trackPosition);
2658             restoreLastTransportState(coordinator, nextAction);
2659         }
2660     }
2661
2662     /**
2663      * Handle the execution of the notification sound by sequentially executing the required steps.
2664      *
2665      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2666      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2667      */
2668     private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator) {
2669         boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2670         String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2671         if (sourceStoppable) {
2672             coordinator.stop();
2673             coordinator.waitForNotTransportState(STATE_PLAYING);
2674             applyNotificationSoundVolume();
2675         }
2676         long notificationPosition = coordinator.getQueueSize() + 1;
2677         coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2678         coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2679         coordinator.setPositionTrack(notificationPosition);
2680         if (!sourceStoppable) {
2681             coordinator.stop();
2682             coordinator.waitForNotTransportState(STATE_PLAYING);
2683             applyNotificationSoundVolume();
2684         }
2685         coordinator.play();
2686         coordinator.waitForFinishedNotification();
2687         if (originalVolume != null) {
2688             setVolumeForGroup(DecimalType.valueOf(originalVolume));
2689         }
2690         coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2691     }
2692
2693     private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction) {
2694         if (nextAction != null) {
2695             switch (nextAction) {
2696                 case STATE_PLAYING:
2697                     coordinator.play();
2698                     coordinator.waitForTransportState(STATE_PLAYING);
2699                     break;
2700                 case STATE_PAUSED_PLAYBACK:
2701                     coordinator.pause();
2702                     break;
2703             }
2704         }
2705     }
2706
2707     /**
2708      * Does a chain of predefined actions when a Notification sound is played by
2709      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2710      * empty queue is currently loaded
2711      *
2712      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2713      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2714      */
2715     private void handleEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator) {
2716         String originalVolume = coordinator.getVolume();
2717         coordinator.applyNotificationSoundVolume();
2718         coordinator.playURI(notificationURL);
2719         coordinator.waitForFinishedNotification();
2720         coordinator.removeAllTracksFromQueue();
2721         if (originalVolume != null) {
2722             coordinator.setVolume(DecimalType.valueOf(originalVolume));
2723         }
2724     }
2725
2726     /**
2727      * Applies the notification sound volume level to the group (if not null)
2728      *
2729      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2730      */
2731     private void applyNotificationSoundVolume() {
2732         setNotificationSoundVolume(getNotificationSoundVolume());
2733     }
2734
2735     private void waitForFinishedNotification() {
2736         waitForTransportState(STATE_PLAYING);
2737
2738         // check Sonos state events to determine the end of the notification sound
2739         String notificationTitle = getCurrentTitle();
2740         long playstart = System.currentTimeMillis();
2741         while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2742             try {
2743                 Thread.sleep(50);
2744                 String currentTitle = getCurrentTitle();
2745                 if ((notificationTitle == null && currentTitle != null)
2746                         || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2747                         || !STATE_PLAYING.equals(getTransportState())) {
2748                     break;
2749                 }
2750             } catch (InterruptedException e) {
2751                 logger.debug("InterruptedException during playing a notification sound");
2752             }
2753         }
2754     }
2755
2756     private void waitForTransportState(String state) {
2757         if (getTransportState() != null) {
2758             long start = System.currentTimeMillis();
2759             while (!state.equals(getTransportState())) {
2760                 try {
2761                     Thread.sleep(50);
2762                     if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2763                         break;
2764                     }
2765                 } catch (InterruptedException e) {
2766                     logger.debug("InterruptedException during playing a notification sound");
2767                 }
2768             }
2769         }
2770     }
2771
2772     private void waitForNotTransportState(String state) {
2773         if (getTransportState() != null) {
2774             long start = System.currentTimeMillis();
2775             while (state.equals(getTransportState())) {
2776                 try {
2777                     Thread.sleep(50);
2778                     if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2779                         break;
2780                     }
2781                 } catch (InterruptedException e) {
2782                     logger.debug("InterruptedException during playing a notification sound");
2783                 }
2784             }
2785         }
2786     }
2787
2788     /**
2789      * Removes a range of tracks from the queue.
2790      * (<x,y> will remove y songs started by the song number x)
2791      *
2792      * @param command - must be in the format <startIndex, numberOfSongs>
2793      */
2794     public void removeRangeOfTracksFromQueue(Command command) {
2795         if (command instanceof StringType) {
2796             Map<String, String> inputs = new HashMap<>();
2797             String[] rangeInputSplit = command.toString().split(",");
2798
2799             // If range input is incorrect, remove the first song by default
2800             String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2801             String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2802
2803             inputs.put("InstanceID", "0");
2804             inputs.put("StartingIndex", startIndex);
2805             inputs.put("NumberOfTracks", numberOfTracks);
2806
2807             Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveTrackRangeFromQueue", inputs);
2808
2809             for (String variable : result.keySet()) {
2810                 this.onValueReceived(variable, result.get(variable), "AVTransport");
2811             }
2812         }
2813     }
2814
2815     public void clearQueue() {
2816         try {
2817             ZonePlayerHandler coordinator = getCoordinatorHandler();
2818
2819             coordinator.removeAllTracksFromQueue();
2820         } catch (IllegalStateException e) {
2821             logger.debug("Cannot clear queue ({})", e.getMessage());
2822         }
2823     }
2824
2825     public void playQueue() {
2826         try {
2827             ZonePlayerHandler coordinator = getCoordinatorHandler();
2828
2829             // set the current playlist to our new queue
2830             coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2831
2832             // take the system off mute
2833             coordinator.setMute(OnOffType.OFF);
2834
2835             // start jammin'
2836             coordinator.play();
2837         } catch (IllegalStateException e) {
2838             logger.debug("Cannot play queue ({})", e.getMessage());
2839         }
2840     }
2841
2842     public void setLed(Command command) {
2843         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2844             Map<String, String> inputs = new HashMap<>();
2845
2846             if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2847                 inputs.put("DesiredLEDState", "On");
2848             } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2849                     || command.equals(OpenClosedType.CLOSED)) {
2850                 inputs.put("DesiredLEDState", "Off");
2851             }
2852
2853             Map<String, String> result = service.invokeAction(this, "DeviceProperties", "SetLEDState", inputs);
2854             Map<String, String> result2 = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
2855
2856             result.putAll(result2);
2857
2858             for (String variable : result.keySet()) {
2859                 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
2860             }
2861         }
2862     }
2863
2864     public void removeMember(Command command) {
2865         if (command instanceof StringType) {
2866             try {
2867                 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2868
2869                 oldmemberHandler.becomeStandAlonePlayer();
2870                 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2871                         QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2872                 oldmemberHandler.setCurrentURI(entry);
2873             } catch (IllegalStateException e) {
2874                 logger.debug("Cannot remove group member ({})", e.getMessage());
2875             }
2876         }
2877     }
2878
2879     public void previous() {
2880         Map<String, String> result = service.invokeAction(this, "AVTransport", "Previous", null);
2881
2882         for (String variable : result.keySet()) {
2883             this.onValueReceived(variable, result.get(variable), "AVTransport");
2884         }
2885     }
2886
2887     public void next() {
2888         Map<String, String> result = service.invokeAction(this, "AVTransport", "Next", null);
2889
2890         for (String variable : result.keySet()) {
2891             this.onValueReceived(variable, result.get(variable), "AVTransport");
2892         }
2893     }
2894
2895     public void stopPlaying(Command command) {
2896         if (command instanceof OnOffType) {
2897             try {
2898                 getCoordinatorHandler().stop();
2899             } catch (IllegalStateException e) {
2900                 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2901             }
2902         }
2903     }
2904
2905     public void playRadio(Command command) {
2906         if (command instanceof StringType) {
2907             String station = command.toString();
2908             List<SonosEntry> stations = getFavoriteRadios();
2909
2910             SonosEntry theEntry = null;
2911             // search for the appropriate radio based on its name (title)
2912             for (SonosEntry someStation : stations) {
2913                 if (someStation.getTitle().equals(station)) {
2914                     theEntry = someStation;
2915                     break;
2916                 }
2917             }
2918
2919             // set the URI of the group coordinator
2920             if (theEntry != null) {
2921                 try {
2922                     ZonePlayerHandler coordinator = getCoordinatorHandler();
2923                     coordinator.setCurrentURI(theEntry);
2924                     coordinator.play();
2925                 } catch (IllegalStateException e) {
2926                     logger.debug("Cannot play radio ({})", e.getMessage());
2927                 }
2928             } else {
2929                 logger.debug("Radio station '{}' not found", station);
2930             }
2931         }
2932     }
2933
2934     public void playTuneinStation(Command command) {
2935         if (command instanceof StringType) {
2936             String stationId = command.toString();
2937             List<SonosMusicService> allServices = getAvailableMusicServices();
2938
2939             SonosMusicService tuneinService = null;
2940             // search for the TuneIn music service based on its name
2941             if (allServices != null) {
2942                 for (SonosMusicService service : allServices) {
2943                     if (service.getName().equals("TuneIn")) {
2944                         tuneinService = service;
2945                         break;
2946                     }
2947                 }
2948             }
2949
2950             // set the URI of the group coordinator
2951             if (tuneinService != null) {
2952                 try {
2953                     ZonePlayerHandler coordinator = getCoordinatorHandler();
2954                     SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
2955                             "object.item.audioItem.audioBroadcast",
2956                             String.format(TUNEIN_URI, stationId, tuneinService.getId()));
2957                     Integer tuneinServiceType = tuneinService.getType();
2958                     int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
2959                     entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
2960                     coordinator.setCurrentURI(entry);
2961                     coordinator.play();
2962                 } catch (IllegalStateException e) {
2963                     logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
2964                 }
2965             } else {
2966                 logger.debug("TuneIn service not found");
2967             }
2968         }
2969     }
2970
2971     private @Nullable List<SonosMusicService> getAvailableMusicServices() {
2972         if (musicServices == null) {
2973             Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
2974
2975             String serviceList = result.get("AvailableServiceDescriptorList");
2976             if (serviceList != null) {
2977                 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
2978                 musicServices = services;
2979
2980                 String[] servicesTypes = new String[0];
2981                 String serviceTypeList = result.get("AvailableServiceTypeList");
2982                 if (serviceTypeList != null) {
2983                     // It is a comma separated list of service types (integers) in the same order as the services
2984                     // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
2985                     // TuneIn service
2986                     servicesTypes = serviceTypeList.split(",");
2987                 }
2988
2989                 int idx = 0;
2990                 for (SonosMusicService service : services) {
2991                     if (!service.getName().equals("TuneIn")) {
2992                         // Add the service type integer value from "AvailableServiceTypeList" to each service
2993                         // except TuneIn
2994                         if (idx < servicesTypes.length) {
2995                             try {
2996                                 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
2997                                 service.setType(serviceType);
2998                             } catch (NumberFormatException e) {
2999                             }
3000                             idx++;
3001                         }
3002                     } else {
3003                         service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3004                     }
3005                     logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3006                             service.getType());
3007                 }
3008             }
3009         }
3010         return musicServices;
3011     }
3012
3013     /**
3014      * This will attempt to match the station string with a entry in the
3015      * favorites list, this supports both single entries and playlists
3016      *
3017      * @param favorite to match
3018      * @return true if a match was found and played.
3019      */
3020     public void playFavorite(Command command) {
3021         if (command instanceof StringType) {
3022             String favorite = command.toString();
3023             List<SonosEntry> favorites = getFavorites();
3024
3025             SonosEntry theEntry = null;
3026             // search for the appropriate favorite based on its name (title)
3027             for (SonosEntry entry : favorites) {
3028                 if (entry.getTitle().equals(favorite)) {
3029                     theEntry = entry;
3030                     break;
3031                 }
3032             }
3033
3034             // set the URI of the group coordinator
3035             if (theEntry != null) {
3036                 try {
3037                     ZonePlayerHandler coordinator = getCoordinatorHandler();
3038
3039                     /**
3040                      * If this is a playlist we need to treat it as such
3041                      */
3042                     SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3043                     if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3044                         coordinator.removeAllTracksFromQueue();
3045                         coordinator.addURIToQueue(theEntry);
3046                         coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3047                         String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3048                         coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3049                     } else {
3050                         coordinator.setCurrentURI(theEntry);
3051                     }
3052                     coordinator.play();
3053                 } catch (IllegalStateException e) {
3054                     logger.debug("Cannot paly favorite ({})", e.getMessage());
3055                 }
3056             } else {
3057                 logger.debug("Favorite '{}' not found", favorite);
3058             }
3059         }
3060     }
3061
3062     public void playTrack(Command command) {
3063         if (command instanceof DecimalType) {
3064             try {
3065                 ZonePlayerHandler coordinator = getCoordinatorHandler();
3066
3067                 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3068
3069                 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3070
3071                 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3072                 coordinator.setPositionTrack(trackNumber);
3073
3074                 // take the system off mute
3075                 coordinator.setMute(OnOffType.OFF);
3076
3077                 // start jammin'
3078                 coordinator.play();
3079             } catch (IllegalStateException e) {
3080                 logger.debug("Cannot play track ({})", e.getMessage());
3081             }
3082         }
3083     }
3084
3085     public void playPlayList(Command command) {
3086         if (command instanceof StringType) {
3087             String playlist = command.toString();
3088             List<SonosEntry> playlists = getPlayLists();
3089
3090             SonosEntry theEntry = null;
3091             // search for the appropriate play list based on its name (title)
3092             for (SonosEntry somePlaylist : playlists) {
3093                 if (somePlaylist.getTitle().equals(playlist)) {
3094                     theEntry = somePlaylist;
3095                     break;
3096                 }
3097             }
3098
3099             // set the URI of the group coordinator
3100             if (theEntry != null) {
3101                 try {
3102                     ZonePlayerHandler coordinator = getCoordinatorHandler();
3103
3104                     coordinator.addURIToQueue(theEntry);
3105
3106                     coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3107
3108                     String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3109                     coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3110
3111                     coordinator.play();
3112                 } catch (IllegalStateException e) {
3113                     logger.debug("Cannot play playlist ({})", e.getMessage());
3114                 }
3115             } else {
3116                 logger.debug("Playlist '{}' not found", playlist);
3117             }
3118         }
3119     }
3120
3121     public void addURIToQueue(SonosEntry newEntry) {
3122         addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3123     }
3124
3125     public @Nullable String getZoneName() {
3126         return stateMap.get("ZoneName");
3127     }
3128
3129     public @Nullable String getZoneGroupID() {
3130         return stateMap.get("LocalGroupUUID");
3131     }
3132
3133     public @Nullable String getRunningAlarmProperties() {
3134         return stateMap.get("RunningAlarmProperties");
3135     }
3136
3137     public @Nullable String getRefreshedRunningAlarmProperties() {
3138         updateRunningAlarmProperties();
3139         return getRunningAlarmProperties();
3140     }
3141
3142     public @Nullable String getMute() {
3143         return stateMap.get("MuteMaster");
3144     }
3145
3146     public boolean isMuted() {
3147         return "1".equals(getMute());
3148     }
3149
3150     public @Nullable String getLed() {
3151         return stateMap.get("CurrentLEDState");
3152     }
3153
3154     public boolean isLedOn() {
3155         return "On".equals(getLed());
3156     }
3157
3158     public @Nullable String getCurrentZoneName() {
3159         return stateMap.get("CurrentZoneName");
3160     }
3161
3162     public @Nullable String getRefreshedCurrentZoneName() {
3163         updateCurrentZoneName();
3164         return getCurrentZoneName();
3165     }
3166
3167     @Override
3168     public void onStatusChanged(boolean status) {
3169         if (status) {
3170             logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3171             if (getThing().getStatus() != ThingStatus.ONLINE) {
3172                 updateStatus(ThingStatus.ONLINE);
3173                 scheduler.execute(this::poll);
3174             }
3175         } else {
3176             logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3177             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3178         }
3179     }
3180
3181     private @Nullable String getModelNameFromDescriptor() {
3182         URL descriptor = service.getDescriptorURL(this);
3183         if (descriptor != null) {
3184             String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3185             return sonosModelDescription == null ? null : SonosXMLParser.extractModelName(sonosModelDescription);
3186         } else {
3187             return null;
3188         }
3189     }
3190
3191     private boolean migrateThingType() {
3192         if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3193             String modelName = getModelNameFromDescriptor();
3194             if (modelName != null && isSupportedModel(modelName)) {
3195                 updateSonosThingType(modelName);
3196                 return true;
3197             }
3198         }
3199         return false;
3200     }
3201
3202     private boolean isSupportedModel(String modelName) {
3203         for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3204             if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3205                 return true;
3206             }
3207         }
3208         return false;
3209     }
3210
3211     private void updateSonosThingType(String newThingTypeID) {
3212         changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3213     }
3214
3215     /*
3216      * Set the sleeptimer duration
3217      * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3218      * Use empty String "" to switch the sleep timer off
3219      */
3220     public void setSleepTimer(Command command) {
3221         if (command instanceof DecimalType) {
3222             Map<String, String> inputs = new HashMap<>();
3223             inputs.put("InstanceID", "0");
3224             inputs.put("NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue()));
3225
3226             this.service.invokeAction(this, "AVTransport", "ConfigureSleepTimer", inputs);
3227         }
3228     }
3229
3230     protected void updateSleepTimerDuration() {
3231         Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRemainingSleepTimerDuration", null);
3232         for (String variable : result.keySet()) {
3233             this.onValueReceived(variable, result.get(variable), "AVTransport");
3234         }
3235     }
3236
3237     private String sleepSecondsToTimeStr(long sleepSeconds) {
3238         if (sleepSeconds == 0) {
3239             return "";
3240         } else if (sleepSeconds < 68400) {
3241             long remainingSeconds = sleepSeconds;
3242             long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3243             remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3244             long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3245             remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3246             long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3247             return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3248         } else {
3249             logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3250             return "ERR";
3251         }
3252     }
3253
3254     private long sleepStrTimeToSeconds(String sleepTime) {
3255         String[] units = sleepTime.split(":");
3256         int hours = Integer.parseInt(units[0]);
3257         int minutes = Integer.parseInt(units[1]);
3258         int seconds = Integer.parseInt(units[2]);
3259         return 3600 * hours + 60 * minutes + seconds;
3260     }
3261 }