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