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