]> git.basschouten.com Git - openhab-addons.git/blob
7f73e186e504f0121c8b47756442879ee32ab453
[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, @Nullable String> stateMap = Collections.synchronizedMap(new HashMap<>());
132
133     private @Nullable ScheduledFuture<?> pollingJob;
134     private @Nullable SonosZonePlayerState savedState;
135
136     private Map<String, @Nullable 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, @Nullable 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, @Nullable 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, @Nullable String> result = service.invokeAction(this, "AVTransport", "GetRunningAlarmProperties",
961                 null);
962
963         String alarmID = result.get("AlarmID");
964         String loggedStartTime = result.get("LoggedStartTime");
965         String newStringValue = null;
966         if (alarmID != null && loggedStartTime != null) {
967             newStringValue = alarmID + " - " + loggedStartTime;
968         } else {
969             newStringValue = "No running alarm";
970         }
971         result.put("RunningAlarmProperties", newStringValue);
972
973         for (String variable : result.keySet()) {
974             this.onValueReceived(variable, result.get(variable), "AVTransport");
975         }
976     }
977
978     protected boolean updateZoneInfo() {
979         Map<String, String> result = service.invokeAction(this, "DeviceProperties", "GetZoneInfo", null);
980         for (String variable : result.keySet()) {
981             this.onValueReceived(variable, result.get(variable), "DeviceProperties");
982         }
983
984         Map<String, String> properties = editProperties();
985         String value = stateMap.get("HardwareVersion");
986         if (value != null && !value.isEmpty()) {
987             properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
988         }
989         value = stateMap.get("DisplaySoftwareVersion");
990         if (value != null && !value.isEmpty()) {
991             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
992         }
993         value = stateMap.get("SerialNumber");
994         if (value != null && !value.isEmpty()) {
995             properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
996         }
997         value = stateMap.get("MACAddress");
998         if (value != null && !value.isEmpty()) {
999             properties.put(MAC_ADDRESS, value);
1000         }
1001         value = stateMap.get("IPAddress");
1002         if (value != null && !value.isEmpty()) {
1003             properties.put(IP_ADDRESS, value);
1004         }
1005         updateProperties(properties);
1006
1007         return !result.isEmpty();
1008     }
1009
1010     public String getCoordinator() {
1011         for (SonosZoneGroup zg : getZoneGroups()) {
1012             if (zg.getMembers().contains(getUDN())) {
1013                 return zg.getCoordinator();
1014             }
1015         }
1016         return getUDN();
1017     }
1018
1019     public boolean isCoordinator() {
1020         return getUDN().equals(getCoordinator());
1021     }
1022
1023     protected void updateMediaInformation() {
1024         String currentURI = getCurrentURI();
1025         SonosMetaData currentTrack = getTrackMetadata();
1026         SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1027
1028         String artist = null;
1029         String album = null;
1030         String title = null;
1031         String resultString = null;
1032         String stationID = null;
1033         boolean needsUpdating = false;
1034
1035         // if currentURI == null, we do nothing
1036         if (currentURI != null) {
1037             if (currentURI.isEmpty()) {
1038                 // Reset data
1039                 needsUpdating = true;
1040             }
1041
1042             // if (currentURI.contains(GROUP_URI)) we do nothing, because
1043             // The Sonos is a slave member of a group
1044             // The media information will be updated by the coordinator
1045             // Notification of group change occurs later, so we just check the URI
1046
1047             else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1048                 // Radio stream (tune-in)
1049                 boolean opmlUrlSucceeded = false;
1050                 stationID = extractStationId(currentURI);
1051                 String url = opmlUrl;
1052                 if (url != null) {
1053                     String mac = getMACAddress();
1054                     if (stationID != null && !stationID.isEmpty() && mac != null && !mac.isEmpty()) {
1055                         url = url.replace("%id", stationID);
1056                         url = url.replace("%serial", mac);
1057
1058                         String response = null;
1059                         try {
1060                             response = HttpUtil.executeUrl("GET", url, SOCKET_TIMEOUT);
1061                         } catch (IOException e) {
1062                             logger.debug("Request to device failed", e);
1063                         }
1064
1065                         if (response != null) {
1066                             List<String> fields = SonosXMLParser.getRadioTimeFromXML(response);
1067
1068                             if (!fields.isEmpty()) {
1069                                 opmlUrlSucceeded = true;
1070
1071                                 resultString = "";
1072                                 for (String field : fields) {
1073                                     if (resultString.isEmpty()) {
1074                                         // radio name should be first field
1075                                         title = field;
1076                                     } else {
1077                                         resultString += " - ";
1078                                     }
1079                                     resultString += field;
1080                                 }
1081
1082                                 needsUpdating = true;
1083                             }
1084                         }
1085                     }
1086                 }
1087                 if (!opmlUrlSucceeded) {
1088                     if (currentUriMetaData != null) {
1089                         title = currentUriMetaData.getTitle();
1090                         if (currentTrack == null || currentTrack.getStreamContent().isEmpty()) {
1091                             resultString = title;
1092                         } else {
1093                             resultString = title + " - " + currentTrack.getStreamContent();
1094                         }
1095                         needsUpdating = true;
1096                     }
1097                 }
1098             }
1099
1100             else if (isPlayingLineIn(currentURI)) {
1101                 if (currentTrack != null) {
1102                     title = currentTrack.getTitle();
1103                     resultString = title;
1104                     needsUpdating = true;
1105                 }
1106             }
1107
1108             else if (isPlayingRadio(currentURI)
1109                     || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1110                 // isPlayingRadio(currentURI) is true for Google Play Music radio or Apple Music radio
1111                 if (currentTrack != null) {
1112                     artist = !currentTrack.getAlbumArtist().isEmpty() ? currentTrack.getAlbumArtist()
1113                             : currentTrack.getCreator();
1114                     album = currentTrack.getAlbum();
1115                     title = currentTrack.getTitle();
1116                     resultString = artist + " - " + album + " - " + title;
1117                     needsUpdating = true;
1118                 }
1119             }
1120         }
1121
1122         String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1123                 ? currentTrack.getAlbumArtUri()
1124                 : "";
1125
1126         ZonePlayerHandler handlerForImageUpdate = null;
1127         for (String member : getZoneGroupMembers()) {
1128             try {
1129                 ZonePlayerHandler memberHandler = getHandlerByName(member);
1130                 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1131                     if (memberHandler.isLinked(CURRENTALBUMART)
1132                             && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1133                         handlerForImageUpdate = memberHandler;
1134                     }
1135                     memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1136                             "AVTransport");
1137                     if (needsUpdating) {
1138                         memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "", "AVTransport");
1139                         memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "", "AVTransport");
1140                         memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "", "AVTransport");
1141                         memberHandler.onValueReceived("CurrentURIFormatted", (resultString != null) ? resultString : "",
1142                                 "AVTransport");
1143                         memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, "AVTransport");
1144                     }
1145                 }
1146             } catch (IllegalStateException e) {
1147                 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1148             }
1149         }
1150         if (needsUpdating && handlerForImageUpdate != null) {
1151             handlerForImageUpdate.updateAlbumArtChannel(true);
1152         }
1153     }
1154
1155     private @Nullable String extractStationId(String uri) {
1156         String stationID = null;
1157         if (isPlayingStream(uri)) {
1158             stationID = substringBetween(uri, ":s", "?sid");
1159         } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1160             stationID = substringBetween(uri, "sid=s", "&");
1161         }
1162         return stationID;
1163     }
1164
1165     private @Nullable String substringBetween(String str, String open, String close) {
1166         String result = null;
1167         int idx1 = str.indexOf(open);
1168         if (idx1 >= 0) {
1169             idx1 += open.length();
1170             int idx2 = str.indexOf(close, idx1);
1171             if (idx2 >= 0) {
1172                 result = str.substring(idx1, idx2);
1173             }
1174         }
1175         return result;
1176     }
1177
1178     public @Nullable String getGroupCoordinatorIsLocal() {
1179         return stateMap.get("GroupCoordinatorIsLocal");
1180     }
1181
1182     public boolean isGroupCoordinator() {
1183         return "true".equals(getGroupCoordinatorIsLocal());
1184     }
1185
1186     @Override
1187     public String getUDN() {
1188         String udn = configuration.udn;
1189         return udn != null && !udn.isEmpty() ? udn : "undefined";
1190     }
1191
1192     public @Nullable String getCurrentURI() {
1193         return stateMap.get("CurrentURI");
1194     }
1195
1196     public @Nullable String getCurrentURIMetadataAsString() {
1197         return stateMap.get("CurrentURIMetaData");
1198     }
1199
1200     public @Nullable SonosMetaData getCurrentURIMetadata() {
1201         String metaData = getCurrentURIMetadataAsString();
1202         return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1203     }
1204
1205     public @Nullable SonosMetaData getTrackMetadata() {
1206         String metaData = stateMap.get("CurrentTrackMetaData");
1207         return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1208     }
1209
1210     public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1211         String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1212         return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1213     }
1214
1215     public @Nullable String getMACAddress() {
1216         String mac = stateMap.get("MACAddress");
1217         if (mac == null || mac.isEmpty()) {
1218             updateZoneInfo();
1219         }
1220         return stateMap.get("MACAddress");
1221     }
1222
1223     public @Nullable String getRefreshedPosition() {
1224         updatePosition();
1225         return stateMap.get("RelTime");
1226     }
1227
1228     public long getRefreshedCurrenTrackNr() {
1229         updatePosition();
1230         String value = stateMap.get("Track");
1231         if (value != null) {
1232             return Long.valueOf(value);
1233         } else {
1234             return -1;
1235         }
1236     }
1237
1238     public @Nullable String getVolume() {
1239         return stateMap.get("VolumeMaster");
1240     }
1241
1242     public @Nullable String getTransportState() {
1243         return stateMap.get("TransportState");
1244     }
1245
1246     public @Nullable String getCurrentTitle() {
1247         return stateMap.get("CurrentTitle");
1248     }
1249
1250     public @Nullable String getCurrentArtist() {
1251         return stateMap.get("CurrentArtist");
1252     }
1253
1254     public @Nullable String getCurrentAlbum() {
1255         return stateMap.get("CurrentAlbum");
1256     }
1257
1258     public List<SonosEntry> getArtists(String filter) {
1259         return getEntries("A:", filter);
1260     }
1261
1262     public List<SonosEntry> getArtists() {
1263         return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1264     }
1265
1266     public List<SonosEntry> getAlbums(String filter) {
1267         return getEntries("A:ALBUM", filter);
1268     }
1269
1270     public List<SonosEntry> getAlbums() {
1271         return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1272     }
1273
1274     public List<SonosEntry> getTracks(String filter) {
1275         return getEntries("A:TRACKS", filter);
1276     }
1277
1278     public List<SonosEntry> getTracks() {
1279         return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1280     }
1281
1282     public List<SonosEntry> getQueue(String filter) {
1283         return getEntries("Q:0", filter);
1284     }
1285
1286     public List<SonosEntry> getQueue() {
1287         return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1288     }
1289
1290     public long getQueueSize() {
1291         return getNbEntries("Q:0");
1292     }
1293
1294     public List<SonosEntry> getPlayLists(String filter) {
1295         return getEntries("SQ:", filter);
1296     }
1297
1298     public List<SonosEntry> getPlayLists() {
1299         return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1300     }
1301
1302     public List<SonosEntry> getFavoriteRadios(String filter) {
1303         return getEntries("R:0/0", filter);
1304     }
1305
1306     public List<SonosEntry> getFavoriteRadios() {
1307         return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1308     }
1309
1310     /**
1311      * Searches for entries in the 'favorites' list on a sonos account
1312      *
1313      * @return
1314      */
1315     public List<SonosEntry> getFavorites() {
1316         return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1317     }
1318
1319     protected List<SonosEntry> getEntries(String type, String filter) {
1320         long startAt = 0;
1321
1322         Map<String, String> inputs = new HashMap<>();
1323         inputs.put("ObjectID", type);
1324         inputs.put("BrowseFlag", "BrowseDirectChildren");
1325         inputs.put("Filter", filter);
1326         inputs.put("StartingIndex", Long.toString(startAt));
1327         inputs.put("RequestedCount", Integer.toString(200));
1328         inputs.put("SortCriteria", "");
1329
1330         Map<String, @Nullable String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1331
1332         String initialResult = result.get("Result");
1333         if (initialResult == null) {
1334             return Collections.emptyList();
1335         }
1336
1337         long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1338         long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1339
1340         List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1341         startAt = startAt + initialNumberReturned;
1342
1343         while (startAt < totalMatches) {
1344             inputs.put("StartingIndex", Long.toString(startAt));
1345             result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1346
1347             // Execute this action synchronously
1348             String nextResult = result.get("Result");
1349             if (nextResult == null) {
1350                 break;
1351             }
1352
1353             long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1354
1355             resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1356
1357             startAt = startAt + numberReturned;
1358         }
1359
1360         return resultList;
1361     }
1362
1363     protected long getNbEntries(String type) {
1364         Map<String, String> inputs = new HashMap<>();
1365         inputs.put("ObjectID", type);
1366         inputs.put("BrowseFlag", "BrowseDirectChildren");
1367         inputs.put("Filter", "dc:title");
1368         inputs.put("StartingIndex", "0");
1369         inputs.put("RequestedCount", "1");
1370         inputs.put("SortCriteria", "");
1371
1372         Map<String, @Nullable String> result = service.invokeAction(this, "ContentDirectory", "Browse", inputs);
1373
1374         return getResultEntry(result, "TotalMatches", type, "dc:title");
1375     }
1376
1377     /**
1378      * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1379      *
1380      * @param resultInput - the map to be examined for the requestedKey
1381      * @param requestedKey - the key to be sought in the resultInput map
1382      * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1383      * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1384      *
1385      * @return 0 as long or the value corresponding to the requiredKey if found
1386      */
1387     private Long getResultEntry(Map<String, @Nullable String> resultInput, String requestedKey, String entriesType,
1388             String entriesFilter) {
1389         long result = 0;
1390
1391         if (resultInput.isEmpty()) {
1392             return result;
1393         }
1394
1395         try {
1396             result = Long.valueOf(resultInput.get(requestedKey));
1397         } catch (NumberFormatException ex) {
1398             logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1399                     requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1400         }
1401
1402         return result;
1403     }
1404
1405     /**
1406      * Save the state (track, position etc) of the Sonos Zone player.
1407      *
1408      * @return true if no error occurred.
1409      */
1410     protected void saveState() {
1411         synchronized (stateLock) {
1412             savedState = new SonosZonePlayerState();
1413             String currentURI = getCurrentURI();
1414
1415             savedState.transportState = getTransportState();
1416             savedState.volume = getVolume();
1417
1418             if (currentURI != null) {
1419                 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
1420                         || isPlayingRadio(currentURI)) {
1421                     // we are streaming music, like tune-in radio or Google Play Music radio
1422                     SonosMetaData track = getTrackMetadata();
1423                     SonosMetaData current = getCurrentURIMetadata();
1424                     if (track != null && current != null) {
1425                         savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1426                                 current.getUpnpClass(), currentURI);
1427                     }
1428                 } else if (currentURI.contains(GROUP_URI)) {
1429                     // we are a slave to some coordinator
1430                     savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1431                 } else if (isPlayingLineIn(currentURI)) {
1432                     // we are streaming from the Line In connection
1433                     savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1434                 } else if (isPlayingQueue(currentURI)) {
1435                     // we are playing something that sits in the queue
1436                     SonosMetaData queued = getEnqueuedTransportURIMetaData();
1437                     if (queued != null) {
1438                         savedState.track = getRefreshedCurrenTrackNr();
1439
1440                         if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1441                             // we are playing a real 'saved' playlist
1442                             List<SonosEntry> playLists = getPlayLists();
1443                             for (SonosEntry someList : playLists) {
1444                                 if (someList.getTitle().equals(queued.getTitle())) {
1445                                     savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1446                                             someList.getParentId(), "", "", "", someList.getUpnpClass(),
1447                                             someList.getRes());
1448                                     break;
1449                                 }
1450                             }
1451                         } else if (queued.getUpnpClass().contains("object.container")) {
1452                             // we are playing some other sort of
1453                             // 'container' - we will save that to a
1454                             // playlist for our convenience
1455                             logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1456
1457                             // save the playlist
1458                             String existingList = "";
1459                             List<SonosEntry> playLists = getPlayLists();
1460                             for (SonosEntry someList : playLists) {
1461                                 if (someList.getTitle().equals(ESH_PREFIX + getUDN())) {
1462                                     existingList = someList.getId();
1463                                     break;
1464                                 }
1465                             }
1466
1467                             saveQueue(ESH_PREFIX + getUDN(), existingList);
1468
1469                             // get all the playlists and a ref to our
1470                             // saved list
1471                             playLists = getPlayLists();
1472                             for (SonosEntry someList : playLists) {
1473                                 if (someList.getTitle().equals(ESH_PREFIX + getUDN())) {
1474                                     savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1475                                             someList.getParentId(), "", "", "", someList.getUpnpClass(),
1476                                             someList.getRes());
1477                                     break;
1478                                 }
1479                             }
1480                         }
1481                     } else {
1482                         savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1483                     }
1484                 }
1485
1486                 savedState.relTime = getRefreshedPosition();
1487             } else {
1488                 savedState.entry = null;
1489             }
1490         }
1491     }
1492
1493     /**
1494      * Restore the state (track, position etc) of the Sonos Zone player.
1495      *
1496      * @return true if no error occurred.
1497      */
1498     protected void restoreState() {
1499         synchronized (stateLock) {
1500             SonosZonePlayerState state = savedState;
1501             if (state != null) {
1502                 // put settings back
1503                 String volume = state.volume;
1504                 if (volume != null) {
1505                     setVolume(DecimalType.valueOf(volume));
1506                 }
1507
1508                 if (isCoordinator()) {
1509                     SonosEntry entry = state.entry;
1510                     if (entry != null) {
1511                         // check if we have a playlist to deal with
1512                         if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1513                             addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1514                             entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1515                             setCurrentURI(entry);
1516                             setPositionTrack(state.track);
1517                         } else {
1518                             setCurrentURI(entry);
1519                             setPosition(state.relTime);
1520                         }
1521                     }
1522
1523                     String transportState = state.transportState;
1524                     if (transportState != null) {
1525                         if (transportState.equals(STATE_PLAYING)) {
1526                             play();
1527                         } else if (transportState.equals(STATE_STOPPED)) {
1528                             stop();
1529                         } else if (transportState.equals(STATE_PAUSED_PLAYBACK)) {
1530                             pause();
1531                         }
1532                     }
1533                 }
1534             }
1535         }
1536     }
1537
1538     public void saveQueue(String name, String queueID) {
1539         Map<String, String> inputs = new HashMap<>();
1540         inputs.put("Title", name);
1541         inputs.put("ObjectID", queueID);
1542
1543         Map<String, String> result = service.invokeAction(this, "AVTransport", "SaveQueue", inputs);
1544
1545         for (String variable : result.keySet()) {
1546             this.onValueReceived(variable, result.get(variable), "AVTransport");
1547         }
1548     }
1549
1550     public void setVolume(Command command) {
1551         if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1552                 || command instanceof PercentType) {
1553             Map<String, String> inputs = new HashMap<>();
1554
1555             String newValue = null;
1556             String currentVolume = getVolume();
1557             if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1558                 int i = Integer.valueOf(currentVolume);
1559                 newValue = String.valueOf(Math.min(100, i + 1));
1560             } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1561                 int i = Integer.valueOf(currentVolume);
1562                 newValue = String.valueOf(Math.max(0, i - 1));
1563             } else if (command == OnOffType.ON) {
1564                 newValue = "100";
1565             } else if (command == OnOffType.OFF) {
1566                 newValue = "0";
1567             } else if (command instanceof DecimalType) {
1568                 newValue = String.valueOf(((DecimalType) command).intValue());
1569             } else {
1570                 return;
1571             }
1572             inputs.put("Channel", "Master");
1573             inputs.put("DesiredVolume", newValue);
1574
1575             Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetVolume", inputs);
1576
1577             for (String variable : result.keySet()) {
1578                 this.onValueReceived(variable, result.get(variable), "RenderingControl");
1579             }
1580         }
1581     }
1582
1583     /**
1584      * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1585      * AdHoc groups handles the volume specifically for each player.
1586      * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1587      */
1588     public void setVolumeForGroup(Command command) {
1589         if (isAdHocGroup() || isStandalonePlayer()) {
1590             setVolume(command);
1591         } else {
1592             try {
1593                 getCoordinatorHandler().setVolume(command);
1594             } catch (IllegalStateException e) {
1595                 logger.debug("Cannot set group volume ({})", e.getMessage());
1596             }
1597         }
1598     }
1599
1600     /**
1601      * Checks if the player receiving the command is part of a group that
1602      * consists of randomly added players or contains bonded players
1603      *
1604      * @return boolean
1605      */
1606     private boolean isAdHocGroup() {
1607         SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1608         if (currentZoneGroup != null) {
1609             List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1610
1611             for (String zoneName : zoneGroupMemberNames) {
1612                 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1613                     // At least one "ZoneName" differs so we have an AdHoc group
1614                     return true;
1615                 }
1616             }
1617         }
1618         return false;
1619     }
1620
1621     /**
1622      * Checks if the player receiving the command is a standalone player
1623      *
1624      * @return boolean
1625      */
1626     private boolean isStandalonePlayer() {
1627         SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1628         return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1629     }
1630
1631     private Collection<SonosZoneGroup> getZoneGroups() {
1632         String zoneGroupState = stateMap.get("ZoneGroupState");
1633         return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1634     }
1635
1636     /**
1637      * Returns the current zone group
1638      * (of which the player receiving the command is part)
1639      *
1640      * @return {@link SonosZoneGroup}
1641      */
1642     private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1643         for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1644             if (zoneGroup.getMembers().contains(getUDN())) {
1645                 return zoneGroup;
1646             }
1647         }
1648         logger.debug("Could not fetch Sonos group state information");
1649         return null;
1650     }
1651
1652     /**
1653      * Sets the volume level for a notification sound
1654      *
1655      * @param notificationSoundVolume
1656      */
1657     public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1658         if (notificationSoundVolume != null) {
1659             setVolumeForGroup(notificationSoundVolume);
1660         }
1661     }
1662
1663     /**
1664      * Gets the volume level for a notification sound
1665      */
1666     public @Nullable PercentType getNotificationSoundVolume() {
1667         Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1668         if (notificationSoundVolume == null) {
1669             // if no value is set we use the current volume instead
1670             String volume = getVolume();
1671             return volume != null ? new PercentType(volume) : null;
1672         }
1673         return new PercentType(notificationSoundVolume);
1674     }
1675
1676     public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1677         Map<String, String> inputs = new HashMap<>();
1678
1679         try {
1680             inputs.put("InstanceID", "0");
1681             inputs.put("EnqueuedURI", URI);
1682             inputs.put("EnqueuedURIMetaData", meta);
1683             inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1684             inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1685         } catch (NumberFormatException ex) {
1686             logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1687         }
1688
1689         Map<String, String> result = service.invokeAction(this, "AVTransport", "AddURIToQueue", inputs);
1690
1691         for (String variable : result.keySet()) {
1692             this.onValueReceived(variable, result.get(variable), "AVTransport");
1693         }
1694     }
1695
1696     public void setCurrentURI(SonosEntry newEntry) {
1697         setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1698     }
1699
1700     public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1701         if (URI != null && URIMetaData != null) {
1702             logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1703             Map<String, String> inputs = new HashMap<>();
1704
1705             try {
1706                 inputs.put("InstanceID", "0");
1707                 inputs.put("CurrentURI", URI);
1708                 inputs.put("CurrentURIMetaData", URIMetaData);
1709             } catch (NumberFormatException ex) {
1710                 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1711             }
1712
1713             Map<String, String> result = service.invokeAction(this, "AVTransport", "SetAVTransportURI", inputs);
1714
1715             for (String variable : result.keySet()) {
1716                 this.onValueReceived(variable, result.get(variable), "AVTransport");
1717             }
1718         }
1719     }
1720
1721     public void setPosition(@Nullable String relTime) {
1722         seek("REL_TIME", relTime);
1723     }
1724
1725     public void setPositionTrack(long tracknr) {
1726         seek("TRACK_NR", Long.toString(tracknr));
1727     }
1728
1729     public void setPositionTrack(String tracknr) {
1730         seek("TRACK_NR", tracknr);
1731     }
1732
1733     protected void seek(String unit, @Nullable String target) {
1734         if (target != null) {
1735             Map<String, String> inputs = new HashMap<>();
1736
1737             try {
1738                 inputs.put("InstanceID", "0");
1739                 inputs.put("Unit", unit);
1740                 inputs.put("Target", target);
1741             } catch (NumberFormatException ex) {
1742                 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1743             }
1744
1745             Map<String, String> result = service.invokeAction(this, "AVTransport", "Seek", inputs);
1746
1747             for (String variable : result.keySet()) {
1748                 this.onValueReceived(variable, result.get(variable), "AVTransport");
1749             }
1750         }
1751     }
1752
1753     public void play() {
1754         Map<String, String> inputs = new HashMap<>();
1755         inputs.put("Speed", "1");
1756
1757         Map<String, String> result = service.invokeAction(this, "AVTransport", "Play", inputs);
1758
1759         for (String variable : result.keySet()) {
1760             this.onValueReceived(variable, result.get(variable), "AVTransport");
1761         }
1762     }
1763
1764     public void stop() {
1765         Map<String, String> result = service.invokeAction(this, "AVTransport", "Stop", null);
1766
1767         for (String variable : result.keySet()) {
1768             this.onValueReceived(variable, result.get(variable), "AVTransport");
1769         }
1770     }
1771
1772     public void pause() {
1773         Map<String, String> result = service.invokeAction(this, "AVTransport", "Pause", null);
1774
1775         for (String variable : result.keySet()) {
1776             this.onValueReceived(variable, result.get(variable), "AVTransport");
1777         }
1778     }
1779
1780     public void setShuffle(Command command) {
1781         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1782             try {
1783                 ZonePlayerHandler coordinator = getCoordinatorHandler();
1784
1785                 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1786                         || command.equals(OpenClosedType.OPEN)) {
1787                     switch (coordinator.getRepeatMode()) {
1788                         case "ALL":
1789                             coordinator.updatePlayMode("SHUFFLE");
1790                             break;
1791                         case "ONE":
1792                             coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
1793                             break;
1794                         case "OFF":
1795                             coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
1796                             break;
1797                     }
1798                 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
1799                         || command.equals(OpenClosedType.CLOSED)) {
1800                     switch (coordinator.getRepeatMode()) {
1801                         case "ALL":
1802                             coordinator.updatePlayMode("REPEAT_ALL");
1803                             break;
1804                         case "ONE":
1805                             coordinator.updatePlayMode("REPEAT_ONE");
1806                             break;
1807                         case "OFF":
1808                             coordinator.updatePlayMode("NORMAL");
1809                             break;
1810                     }
1811                 }
1812             } catch (IllegalStateException e) {
1813                 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
1814             }
1815         }
1816     }
1817
1818     public void setRepeat(Command command) {
1819         if (command instanceof StringType) {
1820             try {
1821                 ZonePlayerHandler coordinator = getCoordinatorHandler();
1822
1823                 switch (command.toString()) {
1824                     case "ALL":
1825                         coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
1826                         break;
1827                     case "ONE":
1828                         coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
1829                         break;
1830                     case "OFF":
1831                         coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
1832                         break;
1833                     default:
1834                         logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
1835                                 command.toString());
1836                         break;
1837                 }
1838             } catch (IllegalStateException e) {
1839                 logger.debug("Cannot handle repeat command ({})", e.getMessage());
1840             }
1841         }
1842     }
1843
1844     public void setNightMode(Command command) {
1845         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1846             setEQ("NightMode", (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1847                     || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
1848         }
1849     }
1850
1851     public void setSpeechEnhancement(Command command) {
1852         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
1853             setEQ("DialogLevel", (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1854                     || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
1855         }
1856     }
1857
1858     private void setEQ(String eqType, String value) {
1859         try {
1860             Map<String, String> inputs = new HashMap<>();
1861             inputs.put("InstanceID", "0");
1862             inputs.put("EQType", eqType);
1863             inputs.put("DesiredValue", value);
1864             Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetEQ", inputs);
1865
1866             for (String variable : result.keySet()) {
1867                 this.onValueReceived(variable, result.get(variable), "RenderingControl");
1868             }
1869         } catch (IllegalStateException e) {
1870             logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
1871         }
1872     }
1873
1874     public @Nullable String getNightMode() {
1875         return stateMap.get("NightMode");
1876     }
1877
1878     public boolean isNightModeOn() {
1879         return "1".equals(getNightMode());
1880     }
1881
1882     public @Nullable String getDialogLevel() {
1883         return stateMap.get("DialogLevel");
1884     }
1885
1886     public boolean isSpeechEnhanced() {
1887         return "1".equals(getDialogLevel());
1888     }
1889
1890     public @Nullable String getPlayMode() {
1891         return stateMap.get("CurrentPlayMode");
1892     }
1893
1894     public Boolean isShuffleActive() {
1895         String playMode = getPlayMode();
1896         return (playMode != null && playMode.startsWith("SHUFFLE"));
1897     }
1898
1899     public String getRepeatMode() {
1900         String mode = "OFF";
1901         String playMode = getPlayMode();
1902         if (playMode != null) {
1903             switch (playMode) {
1904                 case "REPEAT_ALL":
1905                 case "SHUFFLE":
1906                     mode = "ALL";
1907                     break;
1908                 case "REPEAT_ONE":
1909                 case "SHUFFLE_REPEAT_ONE":
1910                     mode = "ONE";
1911                     break;
1912                 case "NORMAL":
1913                 case "SHUFFLE_NOREPEAT":
1914                 default:
1915                     mode = "OFF";
1916                     break;
1917             }
1918         }
1919         return mode;
1920     }
1921
1922     protected void updatePlayMode(String playMode) {
1923         Map<String, String> inputs = new HashMap<>();
1924         inputs.put("InstanceID", "0");
1925         inputs.put("NewPlayMode", playMode);
1926
1927         Map<String, String> result = service.invokeAction(this, "AVTransport", "SetPlayMode", inputs);
1928
1929         for (String variable : result.keySet()) {
1930             this.onValueReceived(variable, result.get(variable), "AVTransport");
1931         }
1932     }
1933
1934     /**
1935      * Clear all scheduled music from the current queue.
1936      *
1937      */
1938     public void removeAllTracksFromQueue() {
1939         Map<String, String> inputs = new HashMap<>();
1940         inputs.put("InstanceID", "0");
1941
1942         Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveAllTracksFromQueue", inputs);
1943
1944         for (String variable : result.keySet()) {
1945             this.onValueReceived(variable, result.get(variable), "AVTransport");
1946         }
1947     }
1948
1949     /**
1950      * Play music from the line-in of the given Player referenced by the given UDN or name
1951      *
1952      * @param udn or name
1953      */
1954     public void playLineIn(Command command) {
1955         if (command instanceof StringType) {
1956             try {
1957                 LineInType lineInType = LineInType.ANY;
1958                 String remotePlayerName = command.toString();
1959                 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
1960                     lineInType = LineInType.ANALOG;
1961                     remotePlayerName = remotePlayerName.substring(7);
1962                 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
1963                     lineInType = LineInType.DIGITAL;
1964                     remotePlayerName = remotePlayerName.substring(8);
1965                 }
1966                 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
1967                 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
1968
1969                 // check if player has a line-in connected
1970                 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
1971                         || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
1972                     // stop whatever is currently playing
1973                     coordinatorHandler.stop();
1974
1975                     // set the URI
1976                     if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
1977                         coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
1978                     } else {
1979                         coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
1980                     }
1981
1982                     // take the system off mute
1983                     coordinatorHandler.setMute(OnOffType.OFF);
1984
1985                     // start jammin'
1986                     coordinatorHandler.play();
1987                 } else {
1988                     logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
1989                 }
1990             } catch (IllegalStateException e) {
1991                 logger.debug("Cannot play line-in ({})", e.getMessage());
1992             }
1993         }
1994     }
1995
1996     private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
1997         ZonePlayerHandler handler = coordinatorHandler;
1998         if (handler != null) {
1999             return handler;
2000         }
2001         try {
2002             handler = getHandlerByName(getCoordinator());
2003             coordinatorHandler = handler;
2004             return handler;
2005         } catch (IllegalStateException e) {
2006             throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2007         }
2008     }
2009
2010     /**
2011      * Returns a list of all zone group members this particular player is member of
2012      * Or empty list if the players is not assigned to any group
2013      *
2014      * @return a list of Strings containing the UDNs of other group members
2015      */
2016     protected List<String> getZoneGroupMembers() {
2017         List<String> result = new ArrayList<>();
2018
2019         Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2020         if (!zoneGroups.isEmpty()) {
2021             for (SonosZoneGroup zg : zoneGroups) {
2022                 if (zg.getMembers().contains(getUDN())) {
2023                     result.addAll(zg.getMembers());
2024                     break;
2025                 }
2026             }
2027         } else {
2028             // If the group topology was not yet received, return at least the current Sonos zone
2029             result.add(getUDN());
2030         }
2031         return result;
2032     }
2033
2034     /**
2035      * Returns a list of other zone group members this particular player is member of
2036      * Or empty list if the players is not assigned to any group
2037      *
2038      * @return a list of Strings containing the UDNs of other group members
2039      */
2040     protected List<String> getOtherZoneGroupMembers() {
2041         List<String> zoneGroupMembers = getZoneGroupMembers();
2042         zoneGroupMembers.remove(getUDN());
2043         return zoneGroupMembers;
2044     }
2045
2046     protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2047         for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2048             Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2049             if (thing != null) {
2050                 ThingHandler handler = thing.getHandler();
2051                 if (handler instanceof ZonePlayerHandler) {
2052                     return (ZonePlayerHandler) handler;
2053                 }
2054             }
2055         }
2056         for (Thing aThing : localThingRegistry.getAll()) {
2057             if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2058                     && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2059                 ThingHandler handler = aThing.getHandler();
2060                 if (handler instanceof ZonePlayerHandler) {
2061                     return (ZonePlayerHandler) handler;
2062                 }
2063             }
2064         }
2065         throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2066     }
2067
2068     public void setMute(Command command) {
2069         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2070             Map<String, String> inputs = new HashMap<>();
2071             inputs.put("Channel", "Master");
2072
2073             if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2074                 inputs.put("DesiredMute", "True");
2075             } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2076                     || command.equals(OpenClosedType.CLOSED)) {
2077                 inputs.put("DesiredMute", "False");
2078             }
2079
2080             Map<String, String> result = service.invokeAction(this, "RenderingControl", "SetMute", inputs);
2081
2082             for (String variable : result.keySet()) {
2083                 this.onValueReceived(variable, result.get(variable), "RenderingControl");
2084             }
2085         }
2086     }
2087
2088     public List<SonosAlarm> getCurrentAlarmList() {
2089         Map<String, @Nullable String> result = service.invokeAction(this, "AlarmClock", "ListAlarms", null);
2090
2091         for (String variable : result.keySet()) {
2092             this.onValueReceived(variable, result.get(variable), "AlarmClock");
2093         }
2094
2095         String alarmList = result.get("CurrentAlarmList");
2096         return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2097     }
2098
2099     public void updateAlarm(SonosAlarm alarm) {
2100         Map<String, String> inputs = new HashMap<>();
2101
2102         try {
2103             inputs.put("ID", Integer.toString(alarm.getId()));
2104             inputs.put("StartLocalTime", alarm.getStartTime());
2105             inputs.put("Duration", alarm.getDuration());
2106             inputs.put("Recurrence", alarm.getRecurrence());
2107             inputs.put("RoomUUID", alarm.getRoomUUID());
2108             inputs.put("ProgramURI", alarm.getProgramURI());
2109             inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2110             inputs.put("PlayMode", alarm.getPlayMode());
2111             inputs.put("Volume", Integer.toString(alarm.getVolume()));
2112             if (alarm.getIncludeLinkedZones()) {
2113                 inputs.put("IncludeLinkedZones", "1");
2114             } else {
2115                 inputs.put("IncludeLinkedZones", "0");
2116             }
2117
2118             if (alarm.getEnabled()) {
2119                 inputs.put("Enabled", "1");
2120             } else {
2121                 inputs.put("Enabled", "0");
2122             }
2123         } catch (NumberFormatException ex) {
2124             logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2125         }
2126
2127         Map<String, String> result = service.invokeAction(this, "AlarmClock", "UpdateAlarm", inputs);
2128
2129         for (String variable : result.keySet()) {
2130             this.onValueReceived(variable, result.get(variable), "AlarmClock");
2131         }
2132     }
2133
2134     public void setAlarm(Command command) {
2135         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2136             if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2137                 setAlarm(true);
2138             } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2139                     || command.equals(OpenClosedType.CLOSED)) {
2140                 setAlarm(false);
2141             }
2142         }
2143     }
2144
2145     public void setAlarm(boolean alarmSwitch) {
2146         List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2147
2148         // find the nearest alarm - take the current time from the Sonos system,
2149         // not the system where we are running
2150         SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2151         fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2152
2153         String currentLocalTime = getTime();
2154         Date currentDateTime = null;
2155         try {
2156             currentDateTime = fmt.parse(currentLocalTime);
2157         } catch (ParseException e) {
2158             logger.debug("An exception occurred while formatting a date", e);
2159         }
2160
2161         if (currentDateTime != null) {
2162             Calendar currentDateTimeCalendar = Calendar.getInstance();
2163             currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2164             currentDateTimeCalendar.setTime(currentDateTime);
2165             currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2166             long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2167
2168             SonosAlarm firstAlarm = null;
2169
2170             for (SonosAlarm anAlarm : sonosAlarms) {
2171                 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2172                 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2173                 Date durationDate;
2174                 try {
2175                     durationDate = durationFormat.parse(anAlarm.getDuration());
2176                 } catch (ParseException e) {
2177                     logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2178                     continue;
2179                 }
2180
2181                 long duration = durationDate.getTime();
2182
2183                 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2184                     shortestDuration = duration;
2185                     firstAlarm = anAlarm;
2186                 }
2187             }
2188
2189             // Set the Alarm
2190             if (firstAlarm != null) {
2191                 if (alarmSwitch) {
2192                     firstAlarm.setEnabled(true);
2193                 } else {
2194                     firstAlarm.setEnabled(false);
2195                 }
2196
2197                 updateAlarm(firstAlarm);
2198             }
2199         }
2200     }
2201
2202     public @Nullable String getTime() {
2203         updateTime();
2204         return stateMap.get("CurrentLocalTime");
2205     }
2206
2207     public @Nullable String getAlarmRunning() {
2208         return stateMap.get("AlarmRunning");
2209     }
2210
2211     public boolean isAlarmRunning() {
2212         return "1".equals(getAlarmRunning());
2213     }
2214
2215     public void snoozeAlarm(Command command) {
2216         if (isAlarmRunning() && command instanceof DecimalType) {
2217             int minutes = ((DecimalType) command).intValue();
2218
2219             Map<String, String> inputs = new HashMap<>();
2220
2221             Calendar snoozePeriod = Calendar.getInstance();
2222             snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2223             snoozePeriod.setTimeInMillis(0);
2224             snoozePeriod.add(Calendar.MINUTE, minutes);
2225             SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2226             pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2227
2228             try {
2229                 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2230             } catch (NumberFormatException ex) {
2231                 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2232             }
2233
2234             Map<String, String> result = service.invokeAction(this, "AVTransport", "SnoozeAlarm", inputs);
2235
2236             for (String variable : result.keySet()) {
2237                 this.onValueReceived(variable, result.get(variable), "AVTransport");
2238             }
2239         } else {
2240             logger.debug("There is no alarm running on {}", getUDN());
2241         }
2242     }
2243
2244     public @Nullable String getAnalogLineInConnected() {
2245         return stateMap.get(LINEINCONNECTED);
2246     }
2247
2248     public boolean isAnalogLineInConnected() {
2249         return "true".equals(getAnalogLineInConnected());
2250     }
2251
2252     public @Nullable String getOpticalLineInConnected() {
2253         return stateMap.get(TOSLINEINCONNECTED);
2254     }
2255
2256     public boolean isOpticalLineInConnected() {
2257         return "true".equals(getOpticalLineInConnected());
2258     }
2259
2260     public void becomeStandAlonePlayer() {
2261         Map<String, String> result = service.invokeAction(this, "AVTransport", "BecomeCoordinatorOfStandaloneGroup",
2262                 null);
2263
2264         for (String variable : result.keySet()) {
2265             this.onValueReceived(variable, result.get(variable), "AVTransport");
2266         }
2267     }
2268
2269     public void addMember(Command command) {
2270         if (command instanceof StringType) {
2271             SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2272             try {
2273                 getHandlerByName(command.toString()).setCurrentURI(entry);
2274             } catch (IllegalStateException e) {
2275                 logger.debug("Cannot add group member ({})", e.getMessage());
2276             }
2277         }
2278     }
2279
2280     public boolean publicAddress(LineInType lineInType) {
2281         // check if sourcePlayer has a line-in connected
2282         if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2283                 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2284             // first remove this player from its own group if any
2285             becomeStandAlonePlayer();
2286
2287             // add all other players to this new group
2288             for (SonosZoneGroup group : getZoneGroups()) {
2289                 for (String player : group.getMembers()) {
2290                     try {
2291                         ZonePlayerHandler somePlayer = getHandlerByName(player);
2292                         if (somePlayer != this) {
2293                             somePlayer.becomeStandAlonePlayer();
2294                             somePlayer.stop();
2295                             addMember(StringType.valueOf(somePlayer.getUDN()));
2296                         }
2297                     } catch (IllegalStateException e) {
2298                         logger.debug("Cannot add to group ({})", e.getMessage());
2299                     }
2300                 }
2301             }
2302
2303             try {
2304                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2305                 // set the URI of the group to the line-in
2306                 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2307                 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2308                     entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2309                 }
2310                 coordinator.setCurrentURI(entry);
2311                 coordinator.play();
2312
2313                 return true;
2314             } catch (IllegalStateException e) {
2315                 logger.debug("Cannot handle command ({})", e.getMessage());
2316                 return false;
2317             }
2318         } else {
2319             logger.debug("Line-in of {} is not connected", getUDN());
2320             return false;
2321         }
2322     }
2323
2324     /**
2325      * Play a given url to music in one of the music libraries.
2326      *
2327      * @param url
2328      *            in the format of //host/folder/filename.mp3
2329      */
2330     public void playURI(Command command) {
2331         if (command instanceof StringType) {
2332             try {
2333                 String url = command.toString();
2334
2335                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2336
2337                 // stop whatever is currently playing
2338                 coordinator.stop();
2339                 coordinator.waitForNotTransportState(STATE_PLAYING);
2340
2341                 // clear any tracks which are pending in the queue
2342                 coordinator.removeAllTracksFromQueue();
2343
2344                 // add the new track we want to play to the queue
2345                 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2346                 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2347                     // default to file based url
2348                     url = FILE_URI + url;
2349                 }
2350                 coordinator.addURIToQueue(url, "", 0, true);
2351
2352                 // set the current playlist to our new queue
2353                 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2354
2355                 // take the system off mute
2356                 coordinator.setMute(OnOffType.OFF);
2357
2358                 // start jammin'
2359                 coordinator.play();
2360             } catch (IllegalStateException e) {
2361                 logger.debug("Cannot play URI ({})", e.getMessage());
2362             }
2363         }
2364     }
2365
2366     private void scheduleNotificationSound(final Command command) {
2367         scheduler.submit(() -> {
2368             synchronized (notificationLock) {
2369                 playNotificationSoundURI(command);
2370             }
2371         });
2372     }
2373
2374     /**
2375      * Play a given notification sound
2376      *
2377      * @param url in the format of //host/folder/filename.mp3
2378      */
2379     public void playNotificationSoundURI(Command notificationURL) {
2380         if (notificationURL instanceof StringType) {
2381             try {
2382                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2383
2384                 String currentURI = coordinator.getCurrentURI();
2385                 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2386                         coordinator.getCurrentURIMetadataAsString());
2387
2388                 if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2389                         || isPlayingRadio(currentURI)) {
2390                     handleRadioStream(currentURI, notificationURL, coordinator);
2391                 } else if (isPlayingLineIn(currentURI)) {
2392                     handleLineIn(currentURI, notificationURL, coordinator);
2393                 } else if (isPlayingQueue(currentURI)) {
2394                     handleSharedQueue(currentURI, notificationURL, coordinator);
2395                 } else if (isPlaylistEmpty(coordinator)) {
2396                     handleEmptyQueue(notificationURL, coordinator);
2397                 }
2398                 synchronized (notificationLock) {
2399                     notificationLock.notify();
2400                 }
2401             } catch (IllegalStateException e) {
2402                 logger.debug("Cannot play sound ({})", e.getMessage());
2403             }
2404         }
2405     }
2406
2407     private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2408         return coordinator.getQueueSize() == 0;
2409     }
2410
2411     private boolean isPlayingQueue(@Nullable String currentURI) {
2412         return currentURI != null && currentURI.contains(QUEUE_URI);
2413     }
2414
2415     private boolean isPlayingStream(@Nullable String currentURI) {
2416         return currentURI != null && currentURI.contains(STREAM_URI);
2417     }
2418
2419     private boolean isPlayingRadio(@Nullable String currentURI) {
2420         return currentURI != null && currentURI.contains(RADIO_URI);
2421     }
2422
2423     private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2424         return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2425     }
2426
2427     private boolean isPlayingLineIn(@Nullable String currentURI) {
2428         return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2429     }
2430
2431     private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2432         return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2433     }
2434
2435     private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2436         return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2437     }
2438
2439     /**
2440      * Does a chain of predefined actions when a Notification sound is played by
2441      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2442      * radio streaming is currently loaded
2443      *
2444      * @param currentStreamURI - the currently loaded stream's URI
2445      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2446      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2447      */
2448     private void handleRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2449             ZonePlayerHandler coordinator) {
2450         String nextAction = coordinator.getTransportState();
2451         SonosMetaData track = coordinator.getTrackMetadata();
2452         SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2453
2454         handleNotificationSound(notificationURL, coordinator);
2455         if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2456             coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2457                     "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2458             restoreLastTransportState(coordinator, nextAction);
2459         }
2460     }
2461
2462     /**
2463      * Does a chain of predefined actions when a Notification sound is played by
2464      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2465      * line in is currently loaded
2466      *
2467      * @param currentLineInURI - the currently loaded line-in URI
2468      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2469      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2470      */
2471     private void handleLineIn(@Nullable String currentLineInURI, Command notificationURL,
2472             ZonePlayerHandler coordinator) {
2473         logger.debug("Handling notification while sound from line-in was being played");
2474         String nextAction = coordinator.getTransportState();
2475
2476         handleNotificationSound(notificationURL, coordinator);
2477         if (currentLineInURI != null) {
2478             logger.debug("Restoring sound from line-in using {}", currentLineInURI);
2479             coordinator.setCurrentURI(currentLineInURI, "");
2480             restoreLastTransportState(coordinator, nextAction);
2481         }
2482     }
2483
2484     /**
2485      * Does a chain of predefined actions when a Notification sound is played by
2486      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2487      * shared queue is currently loaded
2488      *
2489      * @param currentQueueURI - the currently loaded queue URI
2490      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2491      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2492      */
2493     private void handleSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2494             ZonePlayerHandler coordinator) {
2495         String nextAction = coordinator.getTransportState();
2496         String trackPosition = coordinator.getRefreshedPosition();
2497         long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2498         logger.debug("handleSharedQueue: currentQueueURI {} trackPosition {} currentTrackNumber {}", currentQueueURI,
2499                 trackPosition, currentTrackNumber);
2500
2501         handleNotificationSound(notificationURL, coordinator);
2502         String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2503         if (queueUri.equals(currentQueueURI)) {
2504             coordinator.setPositionTrack(currentTrackNumber);
2505             coordinator.setPosition(trackPosition);
2506             restoreLastTransportState(coordinator, nextAction);
2507         }
2508     }
2509
2510     /**
2511      * Handle the execution of the notification sound by sequentially executing the required steps.
2512      *
2513      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2514      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2515      */
2516     private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator) {
2517         boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2518         String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2519         if (sourceStoppable) {
2520             coordinator.stop();
2521             coordinator.waitForNotTransportState(STATE_PLAYING);
2522             applyNotificationSoundVolume();
2523         }
2524         long notificationPosition = coordinator.getQueueSize() + 1;
2525         coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2526         coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2527         coordinator.setPositionTrack(notificationPosition);
2528         if (!sourceStoppable) {
2529             coordinator.stop();
2530             coordinator.waitForNotTransportState(STATE_PLAYING);
2531             applyNotificationSoundVolume();
2532         }
2533         coordinator.play();
2534         coordinator.waitForFinishedNotification();
2535         if (originalVolume != null) {
2536             setVolumeForGroup(DecimalType.valueOf(originalVolume));
2537         }
2538         coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2539     }
2540
2541     private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction) {
2542         if (nextAction != null) {
2543             switch (nextAction) {
2544                 case STATE_PLAYING:
2545                     coordinator.play();
2546                     coordinator.waitForTransportState(STATE_PLAYING);
2547                     break;
2548                 case STATE_PAUSED_PLAYBACK:
2549                     coordinator.pause();
2550                     break;
2551             }
2552         }
2553     }
2554
2555     /**
2556      * Does a chain of predefined actions when a Notification sound is played by
2557      * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2558      * empty queue is currently loaded
2559      *
2560      * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2561      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2562      */
2563     private void handleEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator) {
2564         String originalVolume = coordinator.getVolume();
2565         coordinator.applyNotificationSoundVolume();
2566         coordinator.playURI(notificationURL);
2567         coordinator.waitForFinishedNotification();
2568         coordinator.removeAllTracksFromQueue();
2569         if (originalVolume != null) {
2570             coordinator.setVolume(DecimalType.valueOf(originalVolume));
2571         }
2572     }
2573
2574     /**
2575      * Applies the notification sound volume level to the group (if not null)
2576      *
2577      * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2578      */
2579     private void applyNotificationSoundVolume() {
2580         setNotificationSoundVolume(getNotificationSoundVolume());
2581     }
2582
2583     private void waitForFinishedNotification() {
2584         waitForTransportState(STATE_PLAYING);
2585
2586         // check Sonos state events to determine the end of the notification sound
2587         String notificationTitle = getCurrentTitle();
2588         long playstart = System.currentTimeMillis();
2589         while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2590             try {
2591                 Thread.sleep(50);
2592                 String currentTitle = getCurrentTitle();
2593                 if ((notificationTitle == null && currentTitle != null)
2594                         || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2595                         || !STATE_PLAYING.equals(getTransportState())) {
2596                     break;
2597                 }
2598             } catch (InterruptedException e) {
2599                 logger.debug("InterruptedException during playing a notification sound");
2600             }
2601         }
2602     }
2603
2604     private void waitForTransportState(String state) {
2605         if (getTransportState() != null) {
2606             long start = System.currentTimeMillis();
2607             while (!state.equals(getTransportState())) {
2608                 try {
2609                     Thread.sleep(50);
2610                     if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2611                         break;
2612                     }
2613                 } catch (InterruptedException e) {
2614                     logger.debug("InterruptedException during playing a notification sound");
2615                 }
2616             }
2617         }
2618     }
2619
2620     private void waitForNotTransportState(String state) {
2621         if (getTransportState() != null) {
2622             long start = System.currentTimeMillis();
2623             while (state.equals(getTransportState())) {
2624                 try {
2625                     Thread.sleep(50);
2626                     if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2627                         break;
2628                     }
2629                 } catch (InterruptedException e) {
2630                     logger.debug("InterruptedException during playing a notification sound");
2631                 }
2632             }
2633         }
2634     }
2635
2636     /**
2637      * Removes a range of tracks from the queue.
2638      * (<x,y> will remove y songs started by the song number x)
2639      *
2640      * @param command - must be in the format <startIndex, numberOfSongs>
2641      */
2642     public void removeRangeOfTracksFromQueue(Command command) {
2643         if (command instanceof StringType) {
2644             Map<String, String> inputs = new HashMap<>();
2645             String[] rangeInputSplit = command.toString().split(",");
2646
2647             // If range input is incorrect, remove the first song by default
2648             String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2649             String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2650
2651             inputs.put("InstanceID", "0");
2652             inputs.put("StartingIndex", startIndex);
2653             inputs.put("NumberOfTracks", numberOfTracks);
2654
2655             Map<String, String> result = service.invokeAction(this, "AVTransport", "RemoveTrackRangeFromQueue", inputs);
2656
2657             for (String variable : result.keySet()) {
2658                 this.onValueReceived(variable, result.get(variable), "AVTransport");
2659             }
2660         }
2661     }
2662
2663     public void clearQueue() {
2664         try {
2665             ZonePlayerHandler coordinator = getCoordinatorHandler();
2666
2667             coordinator.removeAllTracksFromQueue();
2668         } catch (IllegalStateException e) {
2669             logger.debug("Cannot clear queue ({})", e.getMessage());
2670         }
2671     }
2672
2673     public void playQueue() {
2674         try {
2675             ZonePlayerHandler coordinator = getCoordinatorHandler();
2676
2677             // set the current playlist to our new queue
2678             coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2679
2680             // take the system off mute
2681             coordinator.setMute(OnOffType.OFF);
2682
2683             // start jammin'
2684             coordinator.play();
2685         } catch (IllegalStateException e) {
2686             logger.debug("Cannot play queue ({})", e.getMessage());
2687         }
2688     }
2689
2690     public void setLed(Command command) {
2691         if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2692             Map<String, String> inputs = new HashMap<>();
2693
2694             if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2695                 inputs.put("DesiredLEDState", "On");
2696             } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2697                     || command.equals(OpenClosedType.CLOSED)) {
2698                 inputs.put("DesiredLEDState", "Off");
2699             }
2700
2701             Map<String, String> result = service.invokeAction(this, "DeviceProperties", "SetLEDState", inputs);
2702             Map<String, String> result2 = service.invokeAction(this, "DeviceProperties", "GetLEDState", null);
2703
2704             result.putAll(result2);
2705
2706             for (String variable : result.keySet()) {
2707                 this.onValueReceived(variable, result.get(variable), "DeviceProperties");
2708             }
2709         }
2710     }
2711
2712     public void removeMember(Command command) {
2713         if (command instanceof StringType) {
2714             try {
2715                 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2716
2717                 oldmemberHandler.becomeStandAlonePlayer();
2718                 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2719                         QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2720                 oldmemberHandler.setCurrentURI(entry);
2721             } catch (IllegalStateException e) {
2722                 logger.debug("Cannot remove group member ({})", e.getMessage());
2723             }
2724         }
2725     }
2726
2727     public void previous() {
2728         Map<String, String> result = service.invokeAction(this, "AVTransport", "Previous", null);
2729
2730         for (String variable : result.keySet()) {
2731             this.onValueReceived(variable, result.get(variable), "AVTransport");
2732         }
2733     }
2734
2735     public void next() {
2736         Map<String, String> result = service.invokeAction(this, "AVTransport", "Next", null);
2737
2738         for (String variable : result.keySet()) {
2739             this.onValueReceived(variable, result.get(variable), "AVTransport");
2740         }
2741     }
2742
2743     public void stopPlaying(Command command) {
2744         if (command instanceof OnOffType) {
2745             try {
2746                 getCoordinatorHandler().stop();
2747             } catch (IllegalStateException e) {
2748                 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2749             }
2750         }
2751     }
2752
2753     public void playRadio(Command command) {
2754         if (command instanceof StringType) {
2755             String station = command.toString();
2756             List<SonosEntry> stations = getFavoriteRadios();
2757
2758             SonosEntry theEntry = null;
2759             // search for the appropriate radio based on its name (title)
2760             for (SonosEntry someStation : stations) {
2761                 if (someStation.getTitle().equals(station)) {
2762                     theEntry = someStation;
2763                     break;
2764                 }
2765             }
2766
2767             // set the URI of the group coordinator
2768             if (theEntry != null) {
2769                 try {
2770                     ZonePlayerHandler coordinator = getCoordinatorHandler();
2771                     coordinator.setCurrentURI(theEntry);
2772                     coordinator.play();
2773                 } catch (IllegalStateException e) {
2774                     logger.debug("Cannot play radio ({})", e.getMessage());
2775                 }
2776             } else {
2777                 logger.debug("Radio station '{}' not found", station);
2778             }
2779         }
2780     }
2781
2782     public void playTuneinStation(Command command) {
2783         if (command instanceof StringType) {
2784             String stationId = command.toString();
2785             List<SonosMusicService> allServices = getAvailableMusicServices();
2786
2787             SonosMusicService tuneinService = null;
2788             // search for the TuneIn music service based on its name
2789             if (allServices != null) {
2790                 for (SonosMusicService service : allServices) {
2791                     if (service.getName().equals("TuneIn")) {
2792                         tuneinService = service;
2793                         break;
2794                     }
2795                 }
2796             }
2797
2798             // set the URI of the group coordinator
2799             if (tuneinService != null) {
2800                 try {
2801                     ZonePlayerHandler coordinator = getCoordinatorHandler();
2802                     SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
2803                             "object.item.audioItem.audioBroadcast",
2804                             String.format(TUNEIN_URI, stationId, tuneinService.getId()));
2805                     Integer tuneinServiceType = tuneinService.getType();
2806                     int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
2807                     entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
2808                     coordinator.setCurrentURI(entry);
2809                     coordinator.play();
2810                 } catch (IllegalStateException e) {
2811                     logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
2812                 }
2813             } else {
2814                 logger.debug("TuneIn service not found");
2815             }
2816         }
2817     }
2818
2819     private @Nullable List<SonosMusicService> getAvailableMusicServices() {
2820         if (musicServices == null) {
2821             Map<String, @Nullable String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices",
2822                     null);
2823
2824             String serviceList = result.get("AvailableServiceDescriptorList");
2825             if (serviceList != null) {
2826                 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
2827                 musicServices = services;
2828
2829                 String[] servicesTypes = new String[0];
2830                 String serviceTypeList = result.get("AvailableServiceTypeList");
2831                 if (serviceTypeList != null) {
2832                     // It is a comma separated list of service types (integers) in the same order as the services
2833                     // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
2834                     // TuneIn service
2835                     servicesTypes = serviceTypeList.split(",");
2836                 }
2837
2838                 int idx = 0;
2839                 for (SonosMusicService service : services) {
2840                     if (!service.getName().equals("TuneIn")) {
2841                         // Add the service type integer value from "AvailableServiceTypeList" to each service
2842                         // except TuneIn
2843                         if (idx < servicesTypes.length) {
2844                             try {
2845                                 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
2846                                 service.setType(serviceType);
2847                             } catch (NumberFormatException e) {
2848                             }
2849                             idx++;
2850                         }
2851                     } else {
2852                         service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
2853                     }
2854                     logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
2855                             service.getType());
2856                 }
2857             }
2858         }
2859         return musicServices;
2860     }
2861
2862     /**
2863      * This will attempt to match the station string with a entry in the
2864      * favorites list, this supports both single entries and playlists
2865      *
2866      * @param favorite to match
2867      * @return true if a match was found and played.
2868      */
2869     public void playFavorite(Command command) {
2870         if (command instanceof StringType) {
2871             String favorite = command.toString();
2872             List<SonosEntry> favorites = getFavorites();
2873
2874             SonosEntry theEntry = null;
2875             // search for the appropriate favorite based on its name (title)
2876             for (SonosEntry entry : favorites) {
2877                 if (entry.getTitle().equals(favorite)) {
2878                     theEntry = entry;
2879                     break;
2880                 }
2881             }
2882
2883             // set the URI of the group coordinator
2884             if (theEntry != null) {
2885                 try {
2886                     ZonePlayerHandler coordinator = getCoordinatorHandler();
2887
2888                     /**
2889                      * If this is a playlist we need to treat it as such
2890                      */
2891                     SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
2892                     if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
2893                         coordinator.removeAllTracksFromQueue();
2894                         coordinator.addURIToQueue(theEntry);
2895                         coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2896                         String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
2897                         coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
2898                     } else {
2899                         coordinator.setCurrentURI(theEntry);
2900                     }
2901                     coordinator.play();
2902                 } catch (IllegalStateException e) {
2903                     logger.debug("Cannot paly favorite ({})", e.getMessage());
2904                 }
2905             } else {
2906                 logger.debug("Favorite '{}' not found", favorite);
2907             }
2908         }
2909     }
2910
2911     public void playTrack(Command command) {
2912         if (command instanceof DecimalType) {
2913             try {
2914                 ZonePlayerHandler coordinator = getCoordinatorHandler();
2915
2916                 String trackNumber = String.valueOf(((DecimalType) command).intValue());
2917
2918                 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2919
2920                 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
2921                 coordinator.setPositionTrack(trackNumber);
2922
2923                 // take the system off mute
2924                 coordinator.setMute(OnOffType.OFF);
2925
2926                 // start jammin'
2927                 coordinator.play();
2928             } catch (IllegalStateException e) {
2929                 logger.debug("Cannot play track ({})", e.getMessage());
2930             }
2931         }
2932     }
2933
2934     public void playPlayList(Command command) {
2935         if (command instanceof StringType) {
2936             String playlist = command.toString();
2937             List<SonosEntry> playlists = getPlayLists();
2938
2939             SonosEntry theEntry = null;
2940             // search for the appropriate play list based on its name (title)
2941             for (SonosEntry somePlaylist : playlists) {
2942                 if (somePlaylist.getTitle().equals(playlist)) {
2943                     theEntry = somePlaylist;
2944                     break;
2945                 }
2946             }
2947
2948             // set the URI of the group coordinator
2949             if (theEntry != null) {
2950                 try {
2951                     ZonePlayerHandler coordinator = getCoordinatorHandler();
2952
2953                     coordinator.addURIToQueue(theEntry);
2954
2955                     coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2956
2957                     String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
2958                     coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
2959
2960                     coordinator.play();
2961                 } catch (IllegalStateException e) {
2962                     logger.debug("Cannot play playlist ({})", e.getMessage());
2963                 }
2964             } else {
2965                 logger.debug("Playlist '{}' not found", playlist);
2966             }
2967         }
2968     }
2969
2970     public void addURIToQueue(SonosEntry newEntry) {
2971         addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
2972     }
2973
2974     public @Nullable String getZoneName() {
2975         return stateMap.get("ZoneName");
2976     }
2977
2978     public @Nullable String getZoneGroupID() {
2979         return stateMap.get("LocalGroupUUID");
2980     }
2981
2982     public @Nullable String getRunningAlarmProperties() {
2983         return stateMap.get("RunningAlarmProperties");
2984     }
2985
2986     public @Nullable String getRefreshedRunningAlarmProperties() {
2987         updateRunningAlarmProperties();
2988         return getRunningAlarmProperties();
2989     }
2990
2991     public @Nullable String getMute() {
2992         return stateMap.get("MuteMaster");
2993     }
2994
2995     public boolean isMuted() {
2996         return "1".equals(getMute());
2997     }
2998
2999     public @Nullable String getLed() {
3000         return stateMap.get("CurrentLEDState");
3001     }
3002
3003     public boolean isLedOn() {
3004         return "On".equals(getLed());
3005     }
3006
3007     public @Nullable String getCurrentZoneName() {
3008         return stateMap.get("CurrentZoneName");
3009     }
3010
3011     public @Nullable String getRefreshedCurrentZoneName() {
3012         updateCurrentZoneName();
3013         return getCurrentZoneName();
3014     }
3015
3016     @Override
3017     public void onStatusChanged(boolean status) {
3018         if (status) {
3019             logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3020             if (getThing().getStatus() != ThingStatus.ONLINE) {
3021                 updateStatus(ThingStatus.ONLINE);
3022                 scheduler.execute(this::poll);
3023             }
3024         } else {
3025             logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3026             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3027         }
3028     }
3029
3030     private @Nullable String getModelNameFromDescriptor() {
3031         URL descriptor = service.getDescriptorURL(this);
3032         if (descriptor != null) {
3033             String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3034             return sonosModelDescription == null ? null : SonosXMLParser.extractModelName(sonosModelDescription);
3035         } else {
3036             return null;
3037         }
3038     }
3039
3040     private boolean migrateThingType() {
3041         if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3042             String modelName = getModelNameFromDescriptor();
3043             if (modelName != null && isSupportedModel(modelName)) {
3044                 updateSonosThingType(modelName);
3045                 return true;
3046             }
3047         }
3048         return false;
3049     }
3050
3051     private boolean isSupportedModel(String modelName) {
3052         for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3053             if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3054                 return true;
3055             }
3056         }
3057         return false;
3058     }
3059
3060     private void updateSonosThingType(String newThingTypeID) {
3061         changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3062     }
3063
3064     /*
3065      * Set the sleeptimer duration
3066      * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3067      * Use empty String "" to switch the sleep timer off
3068      */
3069     public void setSleepTimer(Command command) {
3070         if (command instanceof DecimalType) {
3071             Map<String, String> inputs = new HashMap<>();
3072             inputs.put("InstanceID", "0");
3073             inputs.put("NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue()));
3074
3075             this.service.invokeAction(this, "AVTransport", "ConfigureSleepTimer", inputs);
3076         }
3077     }
3078
3079     protected void updateSleepTimerDuration() {
3080         Map<String, String> result = service.invokeAction(this, "AVTransport", "GetRemainingSleepTimerDuration", null);
3081         for (String variable : result.keySet()) {
3082             this.onValueReceived(variable, result.get(variable), "AVTransport");
3083         }
3084     }
3085
3086     private String sleepSecondsToTimeStr(long sleepSeconds) {
3087         if (sleepSeconds == 0) {
3088             return "";
3089         } else if (sleepSeconds < 68400) {
3090             long remainingSeconds = sleepSeconds;
3091             long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3092             remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3093             long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3094             remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3095             long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3096             return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3097         } else {
3098             logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3099             return "ERR";
3100         }
3101     }
3102
3103     private long sleepStrTimeToSeconds(String sleepTime) {
3104         String[] units = sleepTime.split(":");
3105         int hours = Integer.parseInt(units[0]);
3106         int minutes = Integer.parseInt(units[1]);
3107         int seconds = Integer.parseInt(units[2]);
3108         return 3600 * hours + 60 * minutes + seconds;
3109     }
3110 }