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