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