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