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