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