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