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