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