2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.sonos.internal.handler;
15 import static org.openhab.binding.sonos.internal.SonosBindingConstants.*;
17 import java.net.MalformedURLException;
19 import java.text.ParseException;
20 import java.text.SimpleDateFormat;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Calendar;
24 import java.util.Collection;
25 import java.util.Collections;
26 import java.util.Date;
27 import java.util.HashMap;
28 import java.util.List;
30 import java.util.TimeZone;
31 import java.util.concurrent.ScheduledFuture;
32 import java.util.concurrent.TimeUnit;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.sonos.internal.SonosAlarm;
37 import org.openhab.binding.sonos.internal.SonosBindingConstants;
38 import org.openhab.binding.sonos.internal.SonosEntry;
39 import org.openhab.binding.sonos.internal.SonosMetaData;
40 import org.openhab.binding.sonos.internal.SonosMusicService;
41 import org.openhab.binding.sonos.internal.SonosResourceMetaData;
42 import org.openhab.binding.sonos.internal.SonosStateDescriptionOptionProvider;
43 import org.openhab.binding.sonos.internal.SonosXMLParser;
44 import org.openhab.binding.sonos.internal.SonosZoneGroup;
45 import org.openhab.binding.sonos.internal.SonosZonePlayerState;
46 import org.openhab.binding.sonos.internal.config.ZonePlayerConfiguration;
47 import org.openhab.core.io.net.http.HttpUtil;
48 import org.openhab.core.io.transport.upnp.UpnpIOParticipant;
49 import org.openhab.core.io.transport.upnp.UpnpIOService;
50 import org.openhab.core.library.types.DecimalType;
51 import org.openhab.core.library.types.IncreaseDecreaseType;
52 import org.openhab.core.library.types.NextPreviousType;
53 import org.openhab.core.library.types.OnOffType;
54 import org.openhab.core.library.types.OpenClosedType;
55 import org.openhab.core.library.types.PercentType;
56 import org.openhab.core.library.types.PlayPauseType;
57 import org.openhab.core.library.types.RawType;
58 import org.openhab.core.library.types.StringType;
59 import org.openhab.core.library.types.UpDownType;
60 import org.openhab.core.thing.ChannelUID;
61 import org.openhab.core.thing.Thing;
62 import org.openhab.core.thing.ThingRegistry;
63 import org.openhab.core.thing.ThingStatus;
64 import org.openhab.core.thing.ThingStatusDetail;
65 import org.openhab.core.thing.ThingTypeUID;
66 import org.openhab.core.thing.ThingUID;
67 import org.openhab.core.thing.binding.BaseThingHandler;
68 import org.openhab.core.thing.binding.ThingHandler;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.RefreshType;
71 import org.openhab.core.types.State;
72 import org.openhab.core.types.StateOption;
73 import org.openhab.core.types.UnDefType;
74 import org.slf4j.Logger;
75 import org.slf4j.LoggerFactory;
78 * The {@link ZonePlayerHandler} is responsible for handling commands, which are
79 * sent to one of the channels.
81 * @author Karel Goderis - Initial contribution
84 public class ZonePlayerHandler extends BaseThingHandler implements UpnpIOParticipant {
86 private static final String ANALOG_LINE_IN_URI = "x-rincon-stream:";
87 private static final String OPTICAL_LINE_IN_URI = "x-sonos-htastream:";
88 private static final String VIRTUAL_LINE_IN_URI = "x-sonos-vli:";
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 RADIOAPP_URI = "x-sonosapi-hls:radioapp_";
95 private static final String OPML_TUNE = "http://opml.radiotime.com/Tune.ashx";
96 private static final String FILE_URI = "x-file-cifs:";
97 private static final String SPDIF = ":spdif";
98 private static final String TUNEIN_URI = "x-sonosapi-stream:s%s?sid=%s&flags=32";
100 private static final String STATE_PLAYING = "PLAYING";
101 private static final String STATE_PAUSED_PLAYBACK = "PAUSED_PLAYBACK";
102 private static final String STATE_STOPPED = "STOPPED";
103 private static final String STATE_TRANSITIONING = "TRANSITIONING";
105 private static final String LINEINCONNECTED = "LineInConnected";
106 private static final String TOSLINEINCONNECTED = "TOSLinkConnected";
108 private static final String SERVICE_DEVICE_PROPERTIES = "DeviceProperties";
109 private static final String SERVICE_AV_TRANSPORT = "AVTransport";
110 private static final String SERVICE_RENDERING_CONTROL = "RenderingControl";
111 private static final String SERVICE_ZONE_GROUP_TOPOLOGY = "ZoneGroupTopology";
112 private static final String SERVICE_GROUP_MANAGEMENT = "GroupManagement";
113 private static final String SERVICE_AUDIO_IN = "AudioIn";
114 private static final String SERVICE_HT_CONTROL = "HTControl";
115 private static final String SERVICE_CONTENT_DIRECTORY = "ContentDirectory";
116 private static final String SERVICE_ALARM_CLOCK = "AlarmClock";
118 private static final Collection<String> SERVICE_SUBSCRIPTIONS = Arrays.asList(SERVICE_DEVICE_PROPERTIES,
119 SERVICE_AV_TRANSPORT, SERVICE_ZONE_GROUP_TOPOLOGY, SERVICE_GROUP_MANAGEMENT, SERVICE_RENDERING_CONTROL,
120 SERVICE_AUDIO_IN, SERVICE_HT_CONTROL, SERVICE_CONTENT_DIRECTORY);
121 protected static final int SUBSCRIPTION_DURATION = 1800;
123 private static final String ACTION_GET_ZONE_ATTRIBUTES = "GetZoneAttributes";
124 private static final String ACTION_GET_ZONE_INFO = "GetZoneInfo";
125 private static final String ACTION_GET_LED_STATE = "GetLEDState";
126 private static final String ACTION_SET_LED_STATE = "SetLEDState";
128 private static final String ACTION_GET_POSITION_INFO = "GetPositionInfo";
129 private static final String ACTION_SET_AV_TRANSPORT_URI = "SetAVTransportURI";
130 private static final String ACTION_SEEK = "Seek";
131 private static final String ACTION_PLAY = "Play";
132 private static final String ACTION_STOP = "Stop";
133 private static final String ACTION_PAUSE = "Pause";
134 private static final String ACTION_PREVIOUS = "Previous";
135 private static final String ACTION_NEXT = "Next";
136 private static final String ACTION_ADD_URI_TO_QUEUE = "AddURIToQueue";
137 private static final String ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE = "RemoveTrackRangeFromQueue";
138 private static final String ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE = "RemoveAllTracksFromQueue";
139 private static final String ACTION_SAVE_QUEUE = "SaveQueue";
140 private static final String ACTION_SET_PLAY_MODE = "SetPlayMode";
141 private static final String ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP = "BecomeCoordinatorOfStandaloneGroup";
142 private static final String ACTION_GET_RUNNING_ALARM_PROPERTIES = "GetRunningAlarmProperties";
143 private static final String ACTION_SNOOZE_ALARM = "SnoozeAlarm";
144 private static final String ACTION_GET_REMAINING_SLEEP_TIMER_DURATION = "GetRemainingSleepTimerDuration";
145 private static final String ACTION_CONFIGURE_SLEEP_TIMER = "ConfigureSleepTimer";
147 private static final String ACTION_SET_VOLUME = "SetVolume";
148 private static final String ACTION_SET_MUTE = "SetMute";
149 private static final String ACTION_SET_BASS = "SetBass";
150 private static final String ACTION_SET_TREBLE = "SetTreble";
151 private static final String ACTION_SET_LOUDNESS = "SetLoudness";
152 private static final String ACTION_SET_EQ = "SetEQ";
154 private static final int TUNEIN_DEFAULT_SERVICE_TYPE = 65031;
156 private static final int MIN_BASS = -10;
157 private static final int MAX_BASS = 10;
158 private static final int MIN_TREBLE = -10;
159 private static final int MAX_TREBLE = 10;
160 private static final int MIN_SUBWOOFER_GAIN = -15;
161 private static final int MAX_SUBWOOFER_GAIN = 15;
162 private static final int MIN_SURROUND_LEVEL = -15;
163 private static final int MAX_SURROUND_LEVEL = 15;
164 private static final int MIN_HEIGHT_LEVEL = -10;
165 private static final int MAX_HEIGHT_LEVEL = 10;
167 private final Logger logger = LoggerFactory.getLogger(ZonePlayerHandler.class);
169 private final ThingRegistry localThingRegistry;
170 private final UpnpIOService service;
171 private final @Nullable String opmlUrl;
172 private final SonosStateDescriptionOptionProvider stateDescriptionProvider;
174 private ZonePlayerConfiguration configuration = new ZonePlayerConfiguration();
177 * Intrinsic lock used to synchronize the execution of notification sounds
179 private final Object notificationLock = new Object();
180 private final Object upnpLock = new Object();
181 private final Object stateLock = new Object();
182 private final Object jobLock = new Object();
184 private final Map<String, String> stateMap = Collections.synchronizedMap(new HashMap<>());
186 private @Nullable ScheduledFuture<?> pollingJob;
187 private @Nullable SonosZonePlayerState savedState;
189 private Map<String, Boolean> subscriptionState = new HashMap<>();
192 * Thing handler instance of the coordinator speaker used for control delegation
194 private @Nullable ZonePlayerHandler coordinatorHandler;
196 private @Nullable List<SonosMusicService> musicServices;
198 private enum LineInType {
204 public ZonePlayerHandler(ThingRegistry thingRegistry, Thing thing, UpnpIOService upnpIOService,
205 @Nullable String opmlUrl, SonosStateDescriptionOptionProvider stateDescriptionProvider) {
207 this.localThingRegistry = thingRegistry;
208 this.opmlUrl = opmlUrl;
209 logger.debug("Creating a ZonePlayerHandler for thing '{}'", getThing().getUID());
210 this.service = upnpIOService;
211 this.stateDescriptionProvider = stateDescriptionProvider;
215 public void dispose() {
216 logger.debug("Handler disposed for thing {}", getThing().getUID());
218 ScheduledFuture<?> job = this.pollingJob;
222 this.pollingJob = null;
224 removeSubscription();
225 service.unregisterParticipant(this);
229 public void initialize() {
230 logger.debug("initializing handler for thing {}", getThing().getUID());
232 if (migrateThingType()) {
233 // we change the type, so we might need a different handler -> let's finish
237 configuration = getConfigAs(ZonePlayerConfiguration.class);
238 String udn = configuration.udn;
239 if (udn != null && !udn.isEmpty()) {
240 service.registerParticipant(this);
241 pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, configuration.refresh, TimeUnit.SECONDS);
243 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
244 "@text/offline.conf-error-missing-udn");
245 logger.debug("Cannot initalize the zoneplayer. UDN not set.");
249 private void poll() {
250 synchronized (jobLock) {
251 if (pollingJob == null) {
255 logger.debug("Polling job");
257 // First check if the Sonos zone is set in the UPnP service registry
258 // If not, set the thing state to OFFLINE and wait for the next poll
259 if (!isUpnpDeviceRegistered()) {
260 logger.debug("UPnP device {} not yet registered", getUDN());
261 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
262 "@text/offline.upnp-device-not-registered [\"" + getUDN() + "\"]");
263 synchronized (upnpLock) {
264 subscriptionState = new HashMap<>();
269 // Check if the Sonos zone can be joined
270 // If not, set the thing state to OFFLINE and do nothing else
272 if (getThing().getStatus() != ThingStatus.ONLINE) {
278 if (isLinked(ZONENAME)) {
279 updateCurrentZoneName();
284 // Action GetRemainingSleepTimerDuration is failing for a group slave member (error code 500)
285 if (isLinked(SLEEPTIMER) && isCoordinator()) {
286 updateSleepTimerDuration();
288 } catch (Exception e) {
289 logger.debug("Exception during poll: {}", e.getMessage(), e);
295 public void handleCommand(ChannelUID channelUID, Command command) {
296 if (command == RefreshType.REFRESH) {
297 updateChannel(channelUID.getId());
299 switch (channelUID.getId()) {
306 case NOTIFICATIONSOUND:
307 scheduleNotificationSound(command);
310 stopPlaying(command);
313 setVolumeForGroup(command);
322 setLoudness(command);
325 setSubwoofer(command);
328 setSubwooferGain(command);
331 setSurround(command);
333 case SURROUNDMUSICMODE:
334 setSurroundMusicMode(command);
336 case SURROUNDMUSICLEVEL:
337 setSurroundMusicLevel(command);
339 case SURROUNDTVLEVEL:
340 setSurroundTvLevel(command);
343 setHeightLevel(command);
349 removeMember(command);
352 becomeStandAlonePlayer();
355 publicAddress(LineInType.ANY);
357 case PUBLICANALOGADDRESS:
358 publicAddress(LineInType.ANALOG);
360 case PUBLICDIGITALADDRESS:
361 publicAddress(LineInType.DIGITAL);
366 case TUNEINSTATIONID:
367 playTuneinStation(command);
370 playFavorite(command);
376 snoozeAlarm(command);
379 saveAllPlayerState();
382 restoreAllPlayerState();
391 playPlayList(command);
410 if (command instanceof PlayPauseType) {
411 if (command == PlayPauseType.PLAY) {
412 getCoordinatorHandler().play();
413 } else if (command == PlayPauseType.PAUSE) {
414 getCoordinatorHandler().pause();
417 if (command instanceof NextPreviousType) {
418 if (command == NextPreviousType.NEXT) {
419 getCoordinatorHandler().next();
420 } else if (command == NextPreviousType.PREVIOUS) {
421 getCoordinatorHandler().previous();
424 // Rewind and Fast Forward are currently not implemented by the binding
425 } catch (IllegalStateException e) {
426 logger.debug("Cannot handle control command ({})", e.getMessage());
430 setSleepTimer(command);
439 setNightMode(command);
441 case SPEECHENHANCEMENT:
442 setSpeechEnhancement(command);
450 private void restoreAllPlayerState() {
451 for (Thing aThing : localThingRegistry.getAll()) {
452 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
453 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
454 if (handler != null) {
455 handler.restoreState();
461 private void saveAllPlayerState() {
462 for (Thing aThing : localThingRegistry.getAll()) {
463 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())) {
464 ZonePlayerHandler handler = (ZonePlayerHandler) aThing.getHandler();
465 if (handler != null) {
473 public void onValueReceived(@Nullable String variable, @Nullable String value, @Nullable String service) {
474 if (variable == null || value == null || service == null) {
478 if (getThing().getStatus() == ThingStatus.ONLINE) {
479 logger.trace("Received pair '{}':'{}' (service '{}') for thing '{}'",
480 new Object[] { variable, value, service, this.getThing().getUID() });
482 String oldValue = this.stateMap.get(variable);
483 if (shouldIgnoreVariableUpdate(variable, value, oldValue)) {
487 this.stateMap.put(variable, value);
489 // pre-process some variables, eg XML processing
490 if (SERVICE_AV_TRANSPORT.equals(service) && "LastChange".equals(variable)) {
491 Map<String, String> parsedValues = SonosXMLParser.getAVTransportFromXML(value);
492 parsedValues.forEach((variable1, value1) -> {
493 // Update the transport state after the update of the media information
494 // to not break the notification mechanism
495 if (!"TransportState".equals(variable1)) {
496 onValueReceived(variable1, value1, service);
498 // Translate AVTransportURI/AVTransportURIMetaData to CurrentURI/CurrentURIMetaData
499 // for a compatibility with the result of the action GetMediaInfo
500 if ("AVTransportURI".equals(variable1)) {
501 onValueReceived("CurrentURI", value1, service);
502 } else if ("AVTransportURIMetaData".equals(variable1)) {
503 onValueReceived("CurrentURIMetaData", value1, service);
506 updateMediaInformation();
507 if (parsedValues.get("TransportState") != null) {
508 onValueReceived("TransportState", parsedValues.get("TransportState"), service);
512 if (SERVICE_RENDERING_CONTROL.equals(service) && "LastChange".equals(variable)) {
513 Map<String, String> parsedValues = SonosXMLParser.getRenderingControlFromXML(value);
514 parsedValues.forEach((variable1, value1) -> {
515 onValueReceived(variable1, value1, service);
519 List<StateOption> options = new ArrayList<>();
521 // update the appropriate channel
523 case "TransportState":
524 updateChannel(STATE);
525 updateChannel(CONTROL);
527 dispatchOnAllGroupMembers(variable, value, service);
529 case "CurrentPlayMode":
530 updateChannel(SHUFFLE);
531 updateChannel(REPEAT);
532 dispatchOnAllGroupMembers(variable, value, service);
534 case "CurrentLEDState":
538 updateState(ZONENAME, new StringType(value));
540 case "CurrentZoneName":
541 updateChannel(ZONENAME);
543 case "ZoneGroupState":
544 updateChannel(COORDINATOR);
545 // Update coordinator after a change is made to the grouping of Sonos players
546 updateGroupCoordinator();
547 updateMediaInformation();
548 // Update state and control channels for the group members with the coordinator values
549 String transportState = getTransportState();
550 if (transportState != null) {
551 dispatchOnAllGroupMembers("TransportState", transportState, SERVICE_AV_TRANSPORT);
553 // Update shuffle and repeat channels for the group members with the coordinator values
554 String playMode = getPlayMode();
555 if (playMode != null) {
556 dispatchOnAllGroupMembers("CurrentPlayMode", playMode, SERVICE_AV_TRANSPORT);
559 case "LocalGroupUUID":
560 updateChannel(ZONEGROUPID);
562 case "GroupCoordinatorIsLocal":
563 updateChannel(LOCALCOORDINATOR);
566 updateChannel(VOLUME);
575 updateChannel(TREBLE);
577 case "LoudnessMaster":
578 updateChannel(LOUDNESS);
582 updateChannel(TREBLE);
583 updateChannel(LOUDNESS);
586 updateChannel(SUBWOOFER);
589 updateChannel(SUBWOOFERGAIN);
591 case "SurroundEnabled":
592 updateChannel(SURROUND);
595 updateChannel(SURROUNDMUSICMODE);
597 case "SurroundLevel":
598 updateChannel(SURROUNDTVLEVEL);
601 updateChannel(CODEC);
603 case "MusicSurroundLevel":
604 updateChannel(SURROUNDMUSICLEVEL);
606 case "HeightChannelLevel":
607 updateChannel(HEIGHTLEVEL);
610 updateChannel(NIGHTMODE);
613 updateChannel(SPEECHENHANCEMENT);
615 case LINEINCONNECTED:
616 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
617 updateChannel(LINEIN);
619 if (SonosBindingConstants.WITH_ANALOG_LINEIN_THING_TYPES_UIDS
620 .contains(getThing().getThingTypeUID())) {
621 updateChannel(ANALOGLINEIN);
624 case TOSLINEINCONNECTED:
625 if (SonosBindingConstants.WITH_LINEIN_THING_TYPES_UIDS.contains(getThing().getThingTypeUID())) {
626 updateChannel(LINEIN);
628 if (SonosBindingConstants.WITH_DIGITAL_LINEIN_THING_TYPES_UIDS
629 .contains(getThing().getThingTypeUID())) {
630 updateChannel(DIGITALLINEIN);
634 updateChannel(ALARMRUNNING);
635 updateRunningAlarmProperties();
637 case "RunningAlarmProperties":
638 updateChannel(ALARMPROPERTIES);
640 case "CurrentURIFormatted":
641 updateChannel(CURRENTTRACK);
644 updateChannel(CURRENTTITLE);
646 case "CurrentArtist":
647 updateChannel(CURRENTARTIST);
650 updateChannel(CURRENTALBUM);
653 updateChannel(CURRENTTRANSPORTURI);
655 case "CurrentTrackURI":
656 updateChannel(CURRENTTRACKURI);
658 case "CurrentAlbumArtURI":
659 updateChannel(CURRENTALBUMARTURL);
661 case "CurrentSleepTimerGeneration":
662 if ("0".equals(value)) {
663 updateState(SLEEPTIMER, new DecimalType(0));
666 case "SleepTimerGeneration":
667 if ("0".equals(value)) {
668 updateState(SLEEPTIMER, new DecimalType(0));
670 updateSleepTimerDuration();
673 case "RemainingSleepTimerDuration":
674 updateState(SLEEPTIMER, new DecimalType(sleepStrTimeToSeconds(value)));
676 case "CurrentTuneInStationId":
677 updateChannel(TUNEINSTATIONID);
679 case "SavedQueuesUpdateID": // service ContentDirectoy
680 for (SonosEntry entry : getPlayLists()) {
681 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
683 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), PLAYLIST), options);
685 case "FavoritesUpdateID": // service ContentDirectoy
686 for (SonosEntry entry : getFavorites()) {
687 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
689 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), FAVORITE), options);
691 // For favorite radios, we should have checked the state variable named RadioFavoritesUpdateID
692 // Due to a bug in the data type definition of this state variable, it is not set.
693 // As a workaround, we check the state variable named ContainerUpdateIDs.
694 case "ContainerUpdateIDs": // service ContentDirectoy
695 if (value.startsWith("R:0,") || stateDescriptionProvider
696 .getStateOptions(new ChannelUID(getThing().getUID(), RADIO)) == null) {
697 for (SonosEntry entry : getFavoriteRadios()) {
698 options.add(new StateOption(entry.getTitle(), entry.getTitle()));
700 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), RADIO), options);
704 updateChannel(BATTERYCHARGING);
705 updateChannel(BATTERYLEVEL);
708 updateChannel(MICROPHONE);
716 private void dispatchOnAllGroupMembers(String variable, String value, String service) {
717 if (isCoordinator()) {
718 for (String member : getOtherZoneGroupMembers()) {
720 ZonePlayerHandler memberHandler = getHandlerByName(member);
721 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
722 memberHandler.onValueReceived(variable, value, service);
724 } catch (IllegalStateException e) {
725 logger.debug("Cannot update channel for group member ({})", e.getMessage());
731 private @Nullable String getAlbumArtUrl() {
733 String albumArtURI = stateMap.get("CurrentAlbumArtURI");
734 if (albumArtURI != null) {
735 if (albumArtURI.startsWith("http")) {
737 } else if (albumArtURI.startsWith("/")) {
739 URL serviceDescrUrl = service.getDescriptorURL(this);
740 if (serviceDescrUrl != null) {
741 url = new URL(serviceDescrUrl.getProtocol(), serviceDescrUrl.getHost(),
742 serviceDescrUrl.getPort(), albumArtURI).toExternalForm();
744 } catch (MalformedURLException e) {
745 logger.debug("Failed to build a valid album art URL from {}: {}", albumArtURI, e.getMessage());
752 protected void updateChannel(String channelId) {
753 if (!isLinked(channelId)) {
759 State newState = UnDefType.UNDEF;
763 value = getTransportState();
765 // Ignoring state TRANSITIONING
766 newState = STATE_TRANSITIONING.equals(value) ? null : new StringType(value);
770 value = getTransportState();
771 if (STATE_PLAYING.equals(value)) {
772 newState = PlayPauseType.PLAY;
773 } else if (STATE_STOPPED.equals(value)) {
774 newState = PlayPauseType.PAUSE;
775 } else if (STATE_PAUSED_PLAYBACK.equals(value)) {
776 newState = PlayPauseType.PAUSE;
777 } else if (STATE_TRANSITIONING.equals(value)) {
778 // Ignoring state TRANSITIONING
783 value = getTransportState();
785 newState = STATE_TRANSITIONING.equals(value) ? null : OnOffType.from(STATE_STOPPED.equals(value));
789 if (getPlayMode() != null) {
790 newState = OnOffType.from(isShuffleActive());
794 if (getPlayMode() != null) {
795 newState = new StringType(getRepeatMode());
801 newState = OnOffType.from(value);
805 value = getCurrentZoneName();
807 newState = new StringType(value);
811 value = getZoneGroupID();
813 newState = new StringType(value);
817 newState = new StringType(getCoordinator());
819 case LOCALCOORDINATOR:
820 if (getGroupCoordinatorIsLocal() != null) {
821 newState = OnOffType.from(isGroupCoordinator());
827 newState = new PercentType(value);
832 if (value != null && !isOutputLevelFixed()) {
833 newState = new DecimalType(value);
838 if (value != null && !isOutputLevelFixed()) {
839 newState = new DecimalType(value);
843 value = getLoudness();
844 if (value != null && !isOutputLevelFixed()) {
845 newState = OnOffType.from(value);
851 newState = OnOffType.from(value);
855 value = getSubwooferEnabled();
857 newState = OnOffType.from(value);
861 value = getSubwooferGain();
863 newState = new DecimalType(value);
867 value = getSurroundEnabled();
869 newState = OnOffType.from(value);
872 case SURROUNDMUSICMODE:
873 value = getSurroundMusicMode();
875 newState = new StringType(value);
878 case SURROUNDMUSICLEVEL:
879 value = getSurroundMusicLevel();
881 newState = new DecimalType(value);
884 case SURROUNDTVLEVEL:
885 value = getSurroundTvLevel();
887 newState = new DecimalType(value);
893 newState = new StringType(value);
897 value = getHeightLevel();
899 newState = new DecimalType(value);
903 value = getNightMode();
905 newState = OnOffType.from(value);
908 case SPEECHENHANCEMENT:
909 value = getDialogLevel();
911 newState = OnOffType.from(value);
915 if (getAnalogLineInConnected() != null) {
916 newState = OnOffType.from(isAnalogLineInConnected());
917 } else if (getOpticalLineInConnected() != null) {
918 newState = OnOffType.from(isOpticalLineInConnected());
922 if (getAnalogLineInConnected() != null) {
923 newState = OnOffType.from(isAnalogLineInConnected());
927 if (getOpticalLineInConnected() != null) {
928 newState = OnOffType.from(isOpticalLineInConnected());
932 if (getAlarmRunning() != null) {
933 newState = OnOffType.from(isAlarmRunning());
936 case ALARMPROPERTIES:
937 value = getRunningAlarmProperties();
939 newState = new StringType(value);
943 value = stateMap.get("CurrentURIFormatted");
945 newState = new StringType(value);
949 value = getCurrentTitle();
951 newState = new StringType(value);
955 value = getCurrentArtist();
957 newState = new StringType(value);
961 value = getCurrentAlbum();
963 newState = new StringType(value);
966 case CURRENTALBUMART:
968 updateAlbumArtChannel(false);
970 case CURRENTALBUMARTURL:
971 url = getAlbumArtUrl();
973 newState = new StringType(url);
976 case CURRENTTRANSPORTURI:
977 value = getCurrentURI();
979 newState = new StringType(value);
982 case CURRENTTRACKURI:
983 value = stateMap.get("CurrentTrackURI");
985 newState = new StringType(value);
988 case TUNEINSTATIONID:
989 value = stateMap.get("CurrentTuneInStationId");
991 newState = new StringType(value);
994 case BATTERYCHARGING:
995 value = extractInfoFromMoreInfo("BattChg");
997 newState = OnOffType.from("CHARGING".equalsIgnoreCase(value));
1001 value = extractInfoFromMoreInfo("BattPct");
1002 if (value != null) {
1003 newState = new DecimalType(value);
1007 value = getMicEnabled();
1008 if (value != null) {
1009 newState = OnOffType.from(value);
1016 if (newState != null) {
1017 updateState(channelId, newState);
1021 private void updateAlbumArtChannel(boolean allGroup) {
1022 String url = getAlbumArtUrl();
1024 // We download the cover art in a different thread to not delay the other operations
1025 scheduler.submit(() -> {
1026 RawType image = HttpUtil.downloadImage(url, true, 500000);
1027 updateChannel(CURRENTALBUMART, image != null ? image : UnDefType.UNDEF, allGroup);
1030 updateChannel(CURRENTALBUMART, UnDefType.UNDEF, allGroup);
1034 private void updateChannel(String channeldD, State state, boolean allGroup) {
1036 for (String member : getZoneGroupMembers()) {
1038 ZonePlayerHandler memberHandler = getHandlerByName(member);
1039 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())
1040 && memberHandler.isLinked(channeldD)) {
1041 memberHandler.updateState(channeldD, state);
1043 } catch (IllegalStateException e) {
1044 logger.debug("Cannot update channel for group member ({})", e.getMessage());
1047 } else if (ThingStatus.ONLINE.equals(getThing().getStatus()) && isLinked(channeldD)) {
1048 updateState(channeldD, state);
1053 * CurrentURI will not change, but will trigger change of CurrentURIFormated
1054 * CurrentTrackMetaData will not change, but will trigger change of Title, Artist, Album
1056 private boolean shouldIgnoreVariableUpdate(String variable, String value, @Nullable String oldValue) {
1057 return !hasValueChanged(value, oldValue) && !isQueueEvent(variable);
1060 private boolean hasValueChanged(@Nullable String value, @Nullable String oldValue) {
1061 return oldValue != null ? !oldValue.equals(value) : value != null;
1065 * Similar to the AVTransport eventing, the Queue events its state variables
1066 * as sub values within a synthesized LastChange state variable.
1068 private boolean isQueueEvent(String variable) {
1069 return "LastChange".equals(variable);
1072 private void updateGroupCoordinator() {
1074 coordinatorHandler = getHandlerByName(getCoordinator());
1075 } catch (IllegalStateException e) {
1076 logger.debug("Cannot update the group coordinator ({})", e.getMessage());
1077 coordinatorHandler = null;
1081 private boolean isUpnpDeviceRegistered() {
1082 return service.isRegistered(this);
1085 private void addSubscription() {
1086 synchronized (upnpLock) {
1087 // Set up GENA Subscriptions
1088 if (service.isRegistered(this)) {
1089 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1090 Boolean state = subscriptionState.get(subscription);
1091 if (state == null || !state) {
1092 logger.debug("{}: Subscribing to service {}...", getUDN(), subscription);
1093 service.addSubscription(this, subscription, SUBSCRIPTION_DURATION);
1094 subscriptionState.put(subscription, true);
1101 private void removeSubscription() {
1102 synchronized (upnpLock) {
1103 // Set up GENA Subscriptions
1104 if (service.isRegistered(this)) {
1105 for (String subscription : SERVICE_SUBSCRIPTIONS) {
1106 Boolean state = subscriptionState.get(subscription);
1107 if (state != null && state) {
1108 logger.debug("{}: Unsubscribing from service {}...", getUDN(), subscription);
1109 service.removeSubscription(this, subscription);
1113 subscriptionState = new HashMap<>();
1118 public void onServiceSubscribed(@Nullable String service, boolean succeeded) {
1119 if (service == null) {
1122 synchronized (upnpLock) {
1123 logger.debug("{}: Subscription to service {} {}", getUDN(), service, succeeded ? "succeeded" : "failed");
1124 subscriptionState.put(service, succeeded);
1128 private Map<String, String> executeAction(String serviceId, String actionId, @Nullable Map<String, String> inputs) {
1129 Map<String, String> result = service.invokeAction(this, serviceId, actionId, inputs);
1130 result.forEach((variable, value) -> {
1131 this.onValueReceived(variable, value, serviceId);
1136 private void updatePlayerState() {
1137 if (!updateZoneInfo()) {
1138 if (!ThingStatus.OFFLINE.equals(getThing().getStatus())) {
1139 logger.debug("Sonos player {} is not available in local network", getUDN());
1140 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1141 "@text/offline.not-available-on-network [\"" + getUDN() + "\"]");
1142 synchronized (upnpLock) {
1143 subscriptionState = new HashMap<>();
1146 } else if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
1147 logger.debug("Sonos player {} has been found in local network", getUDN());
1148 updateStatus(ThingStatus.ONLINE);
1152 protected void updateCurrentZoneName() {
1153 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_ATTRIBUTES, null);
1156 protected void updateLed() {
1157 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
1160 protected void updateTime() {
1161 executeAction(SERVICE_ALARM_CLOCK, "GetTimeNow", null);
1164 protected void updatePosition() {
1165 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_POSITION_INFO, null);
1168 protected void updateRunningAlarmProperties() {
1169 Map<String, String> result = service.invokeAction(this, SERVICE_AV_TRANSPORT,
1170 ACTION_GET_RUNNING_ALARM_PROPERTIES, null);
1172 String alarmID = result.get("AlarmID");
1173 String loggedStartTime = result.get("LoggedStartTime");
1174 String newStringValue = null;
1175 if (alarmID != null && loggedStartTime != null) {
1176 newStringValue = alarmID + " - " + loggedStartTime;
1178 newStringValue = "No running alarm";
1180 result.put("RunningAlarmProperties", newStringValue);
1182 result.forEach((variable, value) -> {
1183 this.onValueReceived(variable, value, SERVICE_AV_TRANSPORT);
1187 protected boolean updateZoneInfo() {
1188 Map<String, String> result = executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_ZONE_INFO, null);
1190 Map<String, String> properties = editProperties();
1191 String value = stateMap.get("HardwareVersion");
1192 if (value != null && !value.isEmpty()) {
1193 properties.put(Thing.PROPERTY_HARDWARE_VERSION, value);
1195 value = stateMap.get("DisplaySoftwareVersion");
1196 if (value != null && !value.isEmpty()) {
1197 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, value);
1199 value = stateMap.get("SerialNumber");
1200 if (value != null && !value.isEmpty()) {
1201 properties.put(Thing.PROPERTY_SERIAL_NUMBER, value);
1203 value = stateMap.get("MACAddress");
1204 if (value != null && !value.isEmpty()) {
1205 properties.put(MAC_ADDRESS, value);
1207 value = stateMap.get("IPAddress");
1208 if (value != null && !value.isEmpty()) {
1209 properties.put(IP_ADDRESS, value);
1211 updateProperties(properties);
1213 return !result.isEmpty();
1216 public String getCoordinator() {
1217 for (SonosZoneGroup zg : getZoneGroups()) {
1218 if (zg.getMembers().contains(getUDN())) {
1219 return zg.getCoordinator();
1225 public boolean isCoordinator() {
1226 return getUDN().equals(getCoordinator());
1229 protected void updateMediaInformation() {
1230 String currentURI = getCurrentURI();
1231 SonosMetaData currentTrack = getTrackMetadata();
1232 SonosMetaData currentUriMetaData = getCurrentURIMetadata();
1234 String stationID = null;
1235 SonosMediaInformation mediaInfo = new SonosMediaInformation();
1237 // if currentURI == null, we do nothing
1238 if (currentURI != null) {
1239 if (currentURI.isEmpty()) {
1241 mediaInfo = new SonosMediaInformation(true);
1244 // if (currentURI.contains(GROUP_URI)) we do nothing, because
1245 // The Sonos is a slave member of a group
1246 // The media information will be updated by the coordinator
1247 // Notification of group change occurs later, so we just check the URI
1249 else if (isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)) {
1250 // Radio stream (tune-in)
1251 stationID = extractStationId(currentURI);
1252 mediaInfo = SonosMediaInformation.parseTuneInMediaInfo(buildOpmlUrl(stationID),
1253 currentUriMetaData != null ? currentUriMetaData.getTitle() : null, currentTrack);
1256 else if (isPlayingRadioApp(currentURI)) {
1257 mediaInfo = SonosMediaInformation.parseRadioAppMediaInfo(
1258 currentUriMetaData != null ? currentUriMetaData.getTitle() : null, currentTrack);
1261 else if (isPlayingLineIn(currentURI)) {
1262 mediaInfo = SonosMediaInformation.parseTrackTitle(currentTrack);
1265 else if (isPlayingRadio(currentURI)
1266 || (!currentURI.contains("x-rincon-mp3") && !currentURI.contains("x-sonosapi"))) {
1267 mediaInfo = SonosMediaInformation.parseTrack(currentTrack);
1271 String albumArtURI = (currentTrack != null && !currentTrack.getAlbumArtUri().isEmpty())
1272 ? currentTrack.getAlbumArtUri()
1275 ZonePlayerHandler handlerForImageUpdate = null;
1276 for (String member : getZoneGroupMembers()) {
1278 ZonePlayerHandler memberHandler = getHandlerByName(member);
1279 if (ThingStatus.ONLINE.equals(memberHandler.getThing().getStatus())) {
1280 if (memberHandler.isLinked(CURRENTALBUMART)
1281 && hasValueChanged(albumArtURI, memberHandler.stateMap.get("CurrentAlbumArtURI"))) {
1282 handlerForImageUpdate = memberHandler;
1284 memberHandler.onValueReceived("CurrentTuneInStationId", (stationID != null) ? stationID : "",
1285 SERVICE_AV_TRANSPORT);
1286 if (mediaInfo.needsUpdate()) {
1287 String artist = mediaInfo.getArtist();
1288 String album = mediaInfo.getAlbum();
1289 String title = mediaInfo.getTitle();
1290 String combinedInfo = mediaInfo.getCombinedInfo();
1291 memberHandler.onValueReceived("CurrentArtist", (artist != null) ? artist : "",
1292 SERVICE_AV_TRANSPORT);
1293 memberHandler.onValueReceived("CurrentAlbum", (album != null) ? album : "",
1294 SERVICE_AV_TRANSPORT);
1295 memberHandler.onValueReceived("CurrentTitle", (title != null) ? title : "",
1296 SERVICE_AV_TRANSPORT);
1297 memberHandler.onValueReceived("CurrentURIFormatted", (combinedInfo != null) ? combinedInfo : "",
1298 SERVICE_AV_TRANSPORT);
1299 memberHandler.onValueReceived("CurrentAlbumArtURI", albumArtURI, SERVICE_AV_TRANSPORT);
1302 } catch (IllegalStateException e) {
1303 logger.debug("Cannot update media data for group member ({})", e.getMessage());
1306 if (mediaInfo.needsUpdate() && handlerForImageUpdate != null) {
1307 handlerForImageUpdate.updateAlbumArtChannel(true);
1311 private @Nullable String buildOpmlUrl(@Nullable String stationId) {
1312 String url = opmlUrl;
1313 if (url != null && stationId != null && !stationId.isEmpty()) {
1314 String mac = getMACAddress();
1315 if (mac != null && !mac.isEmpty()) {
1316 url = url.replace("%id", stationId);
1317 url = url.replace("%serial", mac);
1324 private @Nullable String extractStationId(String uri) {
1325 String stationID = null;
1326 if (isPlayingStream(uri)) {
1327 stationID = substringBetween(uri, ":s", "?sid");
1328 } else if (isPlayingRadioStartedByAmazonEcho(uri)) {
1329 stationID = substringBetween(uri, "sid=s", "&");
1334 private @Nullable String substringBetween(String str, String open, String close) {
1335 String result = null;
1336 int idx1 = str.indexOf(open);
1338 idx1 += open.length();
1339 int idx2 = str.indexOf(close, idx1);
1341 result = str.substring(idx1, idx2);
1347 public @Nullable String getGroupCoordinatorIsLocal() {
1348 return stateMap.get("GroupCoordinatorIsLocal");
1351 public boolean isGroupCoordinator() {
1352 return "true".equals(getGroupCoordinatorIsLocal());
1356 public String getUDN() {
1357 String udn = configuration.udn;
1358 return udn != null && !udn.isEmpty() ? udn : "undefined";
1361 public @Nullable String getCurrentURI() {
1362 return stateMap.get("CurrentURI");
1365 public @Nullable String getCurrentURIMetadataAsString() {
1366 return stateMap.get("CurrentURIMetaData");
1369 public @Nullable SonosMetaData getCurrentURIMetadata() {
1370 String metaData = getCurrentURIMetadataAsString();
1371 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1374 public @Nullable SonosMetaData getTrackMetadata() {
1375 String metaData = stateMap.get("CurrentTrackMetaData");
1376 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1379 public @Nullable SonosMetaData getEnqueuedTransportURIMetaData() {
1380 String metaData = stateMap.get("EnqueuedTransportURIMetaData");
1381 return metaData != null && !metaData.isEmpty() ? SonosXMLParser.getMetaDataFromXML(metaData) : null;
1384 public @Nullable String getMACAddress() {
1385 String mac = stateMap.get("MACAddress");
1386 if (mac == null || mac.isEmpty()) {
1389 return stateMap.get("MACAddress");
1392 public @Nullable String getRefreshedPosition() {
1394 return stateMap.get("RelTime");
1397 public long getRefreshedCurrenTrackNr() {
1399 String value = stateMap.get("Track");
1400 if (value != null) {
1401 return Long.valueOf(value);
1407 public @Nullable String getVolume() {
1408 return stateMap.get("VolumeMaster");
1411 public boolean isOutputLevelFixed() {
1412 return "1".equals(stateMap.get("OutputFixed"));
1415 public @Nullable String getBass() {
1416 return stateMap.get("Bass");
1419 public @Nullable String getTreble() {
1420 return stateMap.get("Treble");
1423 public @Nullable String getLoudness() {
1424 return stateMap.get("LoudnessMaster");
1427 public @Nullable String getSurroundEnabled() {
1428 return stateMap.get("SurroundEnabled");
1431 public @Nullable String getSurroundMusicMode() {
1432 return stateMap.get("SurroundMode");
1435 public @Nullable String getSurroundTvLevel() {
1436 return stateMap.get("SurroundLevel");
1439 public @Nullable String getSurroundMusicLevel() {
1440 return stateMap.get("MusicSurroundLevel");
1443 public @Nullable String getCodec() {
1444 String codec = stateMap.get("HTAudioIn");
1445 if (codec != null) {
1460 codec = "dolbyAtmos";
1478 codec = "Unknown - " + codec;
1484 public @Nullable String getSubwooferEnabled() {
1485 return stateMap.get("SubEnabled");
1488 public @Nullable String getSubwooferGain() {
1489 return stateMap.get("SubGain");
1492 public @Nullable String getHeightLevel() {
1493 return stateMap.get("HeightChannelLevel");
1496 public @Nullable String getTransportState() {
1497 return stateMap.get("TransportState");
1500 public @Nullable String getCurrentTitle() {
1501 return stateMap.get("CurrentTitle");
1504 public @Nullable String getCurrentArtist() {
1505 return stateMap.get("CurrentArtist");
1508 public @Nullable String getCurrentAlbum() {
1509 return stateMap.get("CurrentAlbum");
1512 public List<SonosEntry> getArtists(String filter) {
1513 return getEntries("A:", filter);
1516 public List<SonosEntry> getArtists() {
1517 return getEntries("A:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1520 public List<SonosEntry> getAlbums(String filter) {
1521 return getEntries("A:ALBUM", filter);
1524 public List<SonosEntry> getAlbums() {
1525 return getEntries("A:ALBUM", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1528 public List<SonosEntry> getTracks(String filter) {
1529 return getEntries("A:TRACKS", filter);
1532 public List<SonosEntry> getTracks() {
1533 return getEntries("A:TRACKS", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1536 public List<SonosEntry> getQueue(String filter) {
1537 return getEntries("Q:0", filter);
1540 public List<SonosEntry> getQueue() {
1541 return getEntries("Q:0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1544 public long getQueueSize() {
1545 return getNbEntries("Q:0");
1548 public List<SonosEntry> getPlayLists(String filter) {
1549 return getEntries("SQ:", filter);
1552 public List<SonosEntry> getPlayLists() {
1553 return getEntries("SQ:", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1556 public List<SonosEntry> getFavoriteRadios(String filter) {
1557 return getEntries("R:0/0", filter);
1560 public List<SonosEntry> getFavoriteRadios() {
1561 return getEntries("R:0/0", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1565 * Searches for entries in the 'favorites' list on a sonos account
1569 public List<SonosEntry> getFavorites() {
1570 return getEntries("FV:2", "dc:title,res,dc:creator,upnp:artist,upnp:album");
1573 protected List<SonosEntry> getEntries(String type, String filter) {
1576 Map<String, String> inputs = new HashMap<>();
1577 inputs.put("ObjectID", type);
1578 inputs.put("BrowseFlag", "BrowseDirectChildren");
1579 inputs.put("Filter", filter);
1580 inputs.put("StartingIndex", Long.toString(startAt));
1581 inputs.put("RequestedCount", Integer.toString(200));
1582 inputs.put("SortCriteria", "");
1584 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1586 String initialResult = result.get("Result");
1587 if (initialResult == null) {
1588 return Collections.emptyList();
1591 long totalMatches = getResultEntry(result, "TotalMatches", type, filter);
1592 long initialNumberReturned = getResultEntry(result, "NumberReturned", type, filter);
1594 List<SonosEntry> resultList = SonosXMLParser.getEntriesFromString(initialResult);
1595 startAt = startAt + initialNumberReturned;
1597 while (startAt < totalMatches) {
1598 inputs.put("StartingIndex", Long.toString(startAt));
1599 result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1601 // Execute this action synchronously
1602 String nextResult = result.get("Result");
1603 if (nextResult == null) {
1607 long numberReturned = getResultEntry(result, "NumberReturned", type, filter);
1609 resultList.addAll(SonosXMLParser.getEntriesFromString(nextResult));
1611 startAt = startAt + numberReturned;
1617 protected long getNbEntries(String type) {
1618 Map<String, String> inputs = new HashMap<>();
1619 inputs.put("ObjectID", type);
1620 inputs.put("BrowseFlag", "BrowseDirectChildren");
1621 inputs.put("Filter", "dc:title");
1622 inputs.put("StartingIndex", "0");
1623 inputs.put("RequestedCount", "1");
1624 inputs.put("SortCriteria", "");
1626 Map<String, String> result = service.invokeAction(this, SERVICE_CONTENT_DIRECTORY, "Browse", inputs);
1628 return getResultEntry(result, "TotalMatches", type, "dc:title");
1632 * Handles value searching in a SONOS result map (called by {@link #getEntries(String, String)})
1634 * @param resultInput - the map to be examined for the requestedKey
1635 * @param requestedKey - the key to be sought in the resultInput map
1636 * @param entriesType - the 'type' argument of {@link #getEntries(String, String)} method used for logging
1637 * @param entriesFilter - the 'filter' argument of {@link #getEntries(String, String)} method used for logging
1639 * @return 0 as long or the value corresponding to the requiredKey if found
1641 private Long getResultEntry(Map<String, String> resultInput, String requestedKey, String entriesType,
1642 String entriesFilter) {
1645 if (resultInput.isEmpty()) {
1650 String resultString = resultInput.get(requestedKey);
1651 if (resultString == null) {
1652 throw new NumberFormatException("Requested key is null.");
1654 result = Long.valueOf(resultString);
1655 } catch (NumberFormatException ex) {
1656 logger.debug("Could not fetch {} result for type: {} and filter: {}. Using default value '0': {}",
1657 requestedKey, entriesType, entriesFilter, ex.getMessage(), ex);
1664 * Save the state (track, position etc) of the Sonos Zone player.
1666 * @return true if no error occurred.
1668 protected void saveState() {
1669 synchronized (stateLock) {
1670 savedState = new SonosZonePlayerState();
1671 String currentURI = getCurrentURI();
1673 savedState.transportState = getTransportState();
1674 savedState.volume = getVolume();
1676 if (currentURI != null) {
1677 if (isPlayingStreamOrRadio(currentURI)) {
1678 // we are streaming music, like tune-in radio or Google Play Music radio
1679 SonosMetaData track = getTrackMetadata();
1680 SonosMetaData current = getCurrentURIMetadata();
1681 if (track != null && current != null) {
1682 savedState.entry = new SonosEntry("", current.getTitle(), "", "", track.getAlbumArtUri(), "",
1683 current.getUpnpClass(), currentURI);
1685 } else if (currentURI.contains(GROUP_URI)) {
1686 // we are a slave to some coordinator
1687 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1688 } else if (isPlayingLineIn(currentURI)) {
1689 // we are streaming from the Line In connection
1690 savedState.entry = new SonosEntry("", "", "", "", "", "", "", currentURI);
1691 } else if (isPlayingQueue(currentURI)) {
1692 // we are playing something that sits in the queue
1693 SonosMetaData queued = getEnqueuedTransportURIMetaData();
1694 if (queued != null) {
1695 savedState.track = getRefreshedCurrenTrackNr();
1697 if (queued.getUpnpClass().contains("object.container.playlistContainer")) {
1698 // we are playing a real 'saved' playlist
1699 List<SonosEntry> playLists = getPlayLists();
1700 for (SonosEntry someList : playLists) {
1701 if (someList.getTitle().equals(queued.getTitle())) {
1702 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1703 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1708 } else if (queued.getUpnpClass().contains("object.container")) {
1709 // we are playing some other sort of
1710 // 'container' - we will save that to a
1711 // playlist for our convenience
1712 logger.debug("Save State for a container of type {}", queued.getUpnpClass());
1714 // save the playlist
1715 String existingList = "";
1716 List<SonosEntry> playLists = getPlayLists();
1717 for (SonosEntry someList : playLists) {
1718 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1719 existingList = someList.getId();
1724 saveQueue(TITLE_PREFIX + getUDN(), existingList);
1726 // get all the playlists and a ref to our
1728 playLists = getPlayLists();
1729 for (SonosEntry someList : playLists) {
1730 if (someList.getTitle().equals(TITLE_PREFIX + getUDN())) {
1731 savedState.entry = new SonosEntry(someList.getId(), someList.getTitle(),
1732 someList.getParentId(), "", "", "", someList.getUpnpClass(),
1739 savedState.entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1743 savedState.relTime = getRefreshedPosition();
1745 savedState.entry = null;
1751 * Restore the state (track, position etc) of the Sonos Zone player.
1753 * @return true if no error occurred.
1755 protected void restoreState() {
1756 synchronized (stateLock) {
1757 SonosZonePlayerState state = savedState;
1758 if (state != null) {
1759 // put settings back
1760 String volume = state.volume;
1761 if (volume != null) {
1762 setVolume(DecimalType.valueOf(volume));
1765 if (isCoordinator()) {
1766 SonosEntry entry = state.entry;
1767 if (entry != null) {
1768 // check if we have a playlist to deal with
1769 if (entry.getUpnpClass().contains("object.container.playlistContainer")) {
1770 addURIToQueue(entry.getRes(), SonosXMLParser.compileMetadataString(entry), 0, true);
1771 entry = new SonosEntry("", "", "", "", "", "", "", QUEUE_URI + getUDN() + "#0");
1772 setCurrentURI(entry);
1773 setPositionTrack(state.track);
1775 setCurrentURI(entry);
1776 setPosition(state.relTime);
1780 String transportState = state.transportState;
1781 if (STATE_PLAYING.equals(transportState)) {
1783 } else if (STATE_STOPPED.equals(transportState)) {
1785 } else if (STATE_PAUSED_PLAYBACK.equals(transportState)) {
1793 public void saveQueue(String name, String queueID) {
1794 executeAction(SERVICE_AV_TRANSPORT, ACTION_SAVE_QUEUE, Map.of("Title", name, "ObjectID", queueID));
1797 public void setVolume(Command command) {
1798 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType || command instanceof DecimalType
1799 || command instanceof PercentType) {
1800 String newValue = null;
1801 String currentVolume = getVolume();
1802 if (command == IncreaseDecreaseType.INCREASE && currentVolume != null) {
1803 int i = Integer.valueOf(currentVolume);
1804 newValue = String.valueOf(Math.min(100, i + 1));
1805 } else if (command == IncreaseDecreaseType.DECREASE && currentVolume != null) {
1806 int i = Integer.valueOf(currentVolume);
1807 newValue = String.valueOf(Math.max(0, i - 1));
1808 } else if (command == OnOffType.ON) {
1810 } else if (command == OnOffType.OFF) {
1812 } else if (command instanceof DecimalType) {
1813 newValue = String.valueOf(((DecimalType) command).intValue());
1817 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_VOLUME,
1818 Map.of("Channel", "Master", "DesiredVolume", newValue));
1823 * Set the VOLUME command specific to the current grouping according to the Sonos behaviour.
1824 * AdHoc groups handles the volume specifically for each player.
1825 * Bonded groups delegate the volume to the coordinator which applies the same level to all group members.
1827 public void setVolumeForGroup(Command command) {
1828 if (isAdHocGroup() || isStandalonePlayer()) {
1832 getCoordinatorHandler().setVolume(command);
1833 } catch (IllegalStateException e) {
1834 logger.debug("Cannot set group volume ({})", e.getMessage());
1839 public void setBass(Command command) {
1840 if (!isOutputLevelFixed()) {
1841 String newValue = getNewNumericValue(command, getBass(), MIN_BASS, MAX_BASS);
1842 if (newValue != null) {
1843 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_BASS,
1844 Map.of("InstanceID", "0", "DesiredBass", newValue));
1849 public void setTreble(Command command) {
1850 if (!isOutputLevelFixed()) {
1851 String newValue = getNewNumericValue(command, getTreble(), MIN_TREBLE, MAX_TREBLE);
1852 if (newValue != null) {
1853 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_TREBLE,
1854 Map.of("InstanceID", "0", "DesiredTreble", newValue));
1859 private @Nullable String getNewNumericValue(Command command, @Nullable String currentValue, int minValue,
1861 String newValue = null;
1862 if (command instanceof IncreaseDecreaseType || command instanceof DecimalType) {
1863 if (command == IncreaseDecreaseType.INCREASE && currentValue != null) {
1864 int i = Integer.valueOf(currentValue);
1865 newValue = String.valueOf(Math.min(maxValue, i + 1));
1866 } else if (command == IncreaseDecreaseType.DECREASE && currentValue != null) {
1867 int i = Integer.valueOf(currentValue);
1868 newValue = String.valueOf(Math.max(minValue, i - 1));
1869 } else if (command instanceof DecimalType) {
1870 newValue = String.valueOf(((DecimalType) command).intValue());
1876 public void setLoudness(Command command) {
1877 if (!isOutputLevelFixed() && (command instanceof OnOffType || command instanceof OpenClosedType
1878 || command instanceof UpDownType)) {
1879 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
1880 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
1881 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_LOUDNESS,
1882 Map.of("InstanceID", "0", "Channel", "Master", "DesiredLoudness", value));
1887 * Checks if the player receiving the command is part of a group that
1888 * consists of randomly added players or contains bonded players
1892 private boolean isAdHocGroup() {
1893 SonosZoneGroup currentZoneGroup = getCurrentZoneGroup();
1894 if (currentZoneGroup != null) {
1895 List<String> zoneGroupMemberNames = currentZoneGroup.getMemberZoneNames();
1897 for (String zoneName : zoneGroupMemberNames) {
1898 if (!zoneName.equals(zoneGroupMemberNames.get(0))) {
1899 // At least one "ZoneName" differs so we have an AdHoc group
1908 * Checks if the player receiving the command is a standalone player
1912 private boolean isStandalonePlayer() {
1913 SonosZoneGroup zoneGroup = getCurrentZoneGroup();
1914 return zoneGroup == null || zoneGroup.getMembers().size() == 1;
1917 private Collection<SonosZoneGroup> getZoneGroups() {
1918 String zoneGroupState = stateMap.get("ZoneGroupState");
1919 return zoneGroupState == null ? Collections.emptyList() : SonosXMLParser.getZoneGroupFromXML(zoneGroupState);
1923 * Returns the current zone group
1924 * (of which the player receiving the command is part)
1926 * @return {@link SonosZoneGroup}
1928 private @Nullable SonosZoneGroup getCurrentZoneGroup() {
1929 for (SonosZoneGroup zoneGroup : getZoneGroups()) {
1930 if (zoneGroup.getMembers().contains(getUDN())) {
1934 logger.debug("Could not fetch Sonos group state information");
1939 * Sets the volume level for a notification sound
1941 * @param notificationSoundVolume
1943 public void setNotificationSoundVolume(@Nullable PercentType notificationSoundVolume) {
1944 if (notificationSoundVolume != null) {
1945 setVolumeForGroup(notificationSoundVolume);
1950 * Gets the volume level for a notification sound
1952 public @Nullable PercentType getNotificationSoundVolume() {
1953 Integer notificationSoundVolume = getConfigAs(ZonePlayerConfiguration.class).notificationVolume;
1954 if (notificationSoundVolume == null) {
1955 // if no value is set we use the current volume instead
1956 String volume = getVolume();
1957 return volume != null ? new PercentType(volume) : null;
1959 return new PercentType(notificationSoundVolume);
1962 public void addURIToQueue(String URI, String meta, long desiredFirstTrack, boolean enqueueAsNext) {
1963 Map<String, String> inputs = new HashMap<>();
1966 inputs.put("InstanceID", "0");
1967 inputs.put("EnqueuedURI", URI);
1968 inputs.put("EnqueuedURIMetaData", meta);
1969 inputs.put("DesiredFirstTrackNumberEnqueued", Long.toString(desiredFirstTrack));
1970 inputs.put("EnqueueAsNext", Boolean.toString(enqueueAsNext));
1971 } catch (NumberFormatException ex) {
1972 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
1975 executeAction(SERVICE_AV_TRANSPORT, ACTION_ADD_URI_TO_QUEUE, inputs);
1978 public void setCurrentURI(SonosEntry newEntry) {
1979 setCurrentURI(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry));
1982 public void setCurrentURI(@Nullable String URI, @Nullable String URIMetaData) {
1983 if (URI != null && URIMetaData != null) {
1984 logger.debug("setCurrentURI URI {} URIMetaData {}", URI, URIMetaData);
1985 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_AV_TRANSPORT_URI,
1986 Map.of("InstanceID", "0", "CurrentURI", URI, "CurrentURIMetaData", URIMetaData));
1990 public void setPosition(@Nullable String relTime) {
1991 seek("REL_TIME", relTime);
1994 public void setPositionTrack(long tracknr) {
1995 seek("TRACK_NR", Long.toString(tracknr));
1998 public void setPositionTrack(String tracknr) {
1999 seek("TRACK_NR", tracknr);
2002 protected void seek(String unit, @Nullable String target) {
2003 if (target != null) {
2004 executeAction(SERVICE_AV_TRANSPORT, ACTION_SEEK, Map.of("InstanceID", "0", "Unit", unit, "Target", target));
2008 public void play() {
2009 executeAction(SERVICE_AV_TRANSPORT, ACTION_PLAY, Map.of("Speed", "1"));
2012 public void stop() {
2013 executeAction(SERVICE_AV_TRANSPORT, ACTION_STOP, null);
2016 public void pause() {
2017 executeAction(SERVICE_AV_TRANSPORT, ACTION_PAUSE, null);
2020 public void setShuffle(Command command) {
2021 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2023 ZonePlayerHandler coordinator = getCoordinatorHandler();
2025 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2026 || command.equals(OpenClosedType.OPEN)) {
2027 switch (coordinator.getRepeatMode()) {
2029 coordinator.updatePlayMode("SHUFFLE");
2032 coordinator.updatePlayMode("SHUFFLE_REPEAT_ONE");
2035 coordinator.updatePlayMode("SHUFFLE_NOREPEAT");
2038 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2039 || command.equals(OpenClosedType.CLOSED)) {
2040 switch (coordinator.getRepeatMode()) {
2042 coordinator.updatePlayMode("REPEAT_ALL");
2045 coordinator.updatePlayMode("REPEAT_ONE");
2048 coordinator.updatePlayMode("NORMAL");
2052 } catch (IllegalStateException e) {
2053 logger.debug("Cannot handle shuffle command ({})", e.getMessage());
2058 public void setRepeat(Command command) {
2059 if (command instanceof StringType) {
2061 ZonePlayerHandler coordinator = getCoordinatorHandler();
2063 switch (command.toString()) {
2065 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE" : "REPEAT_ALL");
2068 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_REPEAT_ONE" : "REPEAT_ONE");
2071 coordinator.updatePlayMode(coordinator.isShuffleActive() ? "SHUFFLE_NOREPEAT" : "NORMAL");
2074 logger.debug("{}: unexpected repeat command; accepted values are ALL, ONE and OFF",
2075 command.toString());
2078 } catch (IllegalStateException e) {
2079 logger.debug("Cannot handle repeat command ({})", e.getMessage());
2084 public void setSubwoofer(Command command) {
2085 setEqualizerBooleanSetting(command, "SubEnable");
2088 public void setSubwooferGain(Command command) {
2089 setEqualizerNumericSetting(command, "SubGain", getSubwooferGain(), MIN_SUBWOOFER_GAIN, MAX_SUBWOOFER_GAIN);
2092 public void setSurround(Command command) {
2093 setEqualizerBooleanSetting(command, "SurroundEnable");
2096 public void setSurroundMusicMode(Command command) {
2097 if (command instanceof StringType) {
2098 setEQ("SurroundMode", command.toString());
2102 public void setSurroundMusicLevel(Command command) {
2103 setEqualizerNumericSetting(command, "MusicSurroundLevel", getSurroundMusicLevel(), MIN_SURROUND_LEVEL,
2104 MAX_SURROUND_LEVEL);
2107 public void setSurroundTvLevel(Command command) {
2108 setEqualizerNumericSetting(command, "SurroundLevel", getSurroundTvLevel(), MIN_SURROUND_LEVEL,
2109 MAX_SURROUND_LEVEL);
2112 public void setHeightLevel(Command command) {
2113 setEqualizerNumericSetting(command, "HeightChannelLevel", getHeightLevel(), MIN_HEIGHT_LEVEL, MAX_HEIGHT_LEVEL);
2116 public void setNightMode(Command command) {
2117 setEqualizerBooleanSetting(command, "NightMode");
2120 public void setSpeechEnhancement(Command command) {
2121 setEqualizerBooleanSetting(command, "DialogLevel");
2124 private void setEqualizerBooleanSetting(Command command, String eqType) {
2125 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2126 setEQ(eqType, (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2127 || command.equals(OpenClosedType.OPEN)) ? "1" : "0");
2131 private void setEqualizerNumericSetting(Command command, String eqType, @Nullable String currentValue, int minValue,
2133 String newValue = getNewNumericValue(command, currentValue, minValue, maxValue);
2134 if (newValue != null) {
2135 setEQ(eqType, newValue);
2139 private void setEQ(String eqType, String value) {
2141 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_EQ,
2142 Map.of("InstanceID", "0", "EQType", eqType, "DesiredValue", value));
2143 } catch (IllegalStateException e) {
2144 logger.debug("Cannot handle {} command ({})", eqType, e.getMessage());
2148 public @Nullable String getNightMode() {
2149 return stateMap.get("NightMode");
2152 public @Nullable String getDialogLevel() {
2153 return stateMap.get("DialogLevel");
2156 public @Nullable String getPlayMode() {
2157 return stateMap.get("CurrentPlayMode");
2160 public Boolean isShuffleActive() {
2161 String playMode = getPlayMode();
2162 return (playMode != null && playMode.startsWith("SHUFFLE"));
2165 public String getRepeatMode() {
2166 String mode = "OFF";
2167 String playMode = getPlayMode();
2168 if (playMode != null) {
2175 case "SHUFFLE_REPEAT_ONE":
2179 case "SHUFFLE_NOREPEAT":
2188 public @Nullable String getMicEnabled() {
2189 return stateMap.get("MicEnabled");
2192 protected void updatePlayMode(String playMode) {
2193 executeAction(SERVICE_AV_TRANSPORT, ACTION_SET_PLAY_MODE, Map.of("InstanceID", "0", "NewPlayMode", playMode));
2197 * Clear all scheduled music from the current queue.
2200 public void removeAllTracksFromQueue() {
2201 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_ALL_TRACKS_FROM_QUEUE, Map.of("InstanceID", "0"));
2205 * Play music from the line-in of the given Player referenced by the given UDN or name
2207 * @param udn or name
2209 public void playLineIn(Command command) {
2210 if (command instanceof StringType) {
2212 LineInType lineInType = LineInType.ANY;
2213 String remotePlayerName = command.toString();
2214 if (remotePlayerName.toUpperCase().startsWith("ANALOG,")) {
2215 lineInType = LineInType.ANALOG;
2216 remotePlayerName = remotePlayerName.substring(7);
2217 } else if (remotePlayerName.toUpperCase().startsWith("DIGITAL,")) {
2218 lineInType = LineInType.DIGITAL;
2219 remotePlayerName = remotePlayerName.substring(8);
2221 ZonePlayerHandler coordinatorHandler = getCoordinatorHandler();
2222 ZonePlayerHandler remoteHandler = getHandlerByName(remotePlayerName);
2224 // check if player has a line-in connected
2225 if ((lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected())
2226 || (lineInType != LineInType.ANALOG && remoteHandler.isOpticalLineInConnected())) {
2227 // stop whatever is currently playing
2228 coordinatorHandler.stop();
2231 if (lineInType != LineInType.DIGITAL && remoteHandler.isAnalogLineInConnected()) {
2232 coordinatorHandler.setCurrentURI(ANALOG_LINE_IN_URI + remoteHandler.getUDN(), "");
2234 coordinatorHandler.setCurrentURI(OPTICAL_LINE_IN_URI + remoteHandler.getUDN() + SPDIF, "");
2237 // take the system off mute
2238 coordinatorHandler.setMute(OnOffType.OFF);
2241 coordinatorHandler.play();
2243 logger.debug("Line-in of {} is not connected", remoteHandler.getUDN());
2245 } catch (IllegalStateException e) {
2246 logger.debug("Cannot play line-in ({})", e.getMessage());
2251 private ZonePlayerHandler getCoordinatorHandler() throws IllegalStateException {
2252 ZonePlayerHandler handler = coordinatorHandler;
2253 if (handler != null) {
2257 handler = getHandlerByName(getCoordinator());
2258 coordinatorHandler = handler;
2260 } catch (IllegalStateException e) {
2261 throw new IllegalStateException("Missing group coordinator " + getCoordinator());
2266 * Returns a list of all zone group members this particular player is member of
2267 * Or empty list if the players is not assigned to any group
2269 * @return a list of Strings containing the UDNs of other group members
2271 protected List<String> getZoneGroupMembers() {
2272 List<String> result = new ArrayList<>();
2274 Collection<SonosZoneGroup> zoneGroups = getZoneGroups();
2275 if (!zoneGroups.isEmpty()) {
2276 for (SonosZoneGroup zg : zoneGroups) {
2277 if (zg.getMembers().contains(getUDN())) {
2278 result.addAll(zg.getMembers());
2283 // If the group topology was not yet received, return at least the current Sonos zone
2284 result.add(getUDN());
2290 * Returns a list of other zone group members this particular player is member of
2291 * Or empty list if the players is not assigned to any group
2293 * @return a list of Strings containing the UDNs of other group members
2295 protected List<String> getOtherZoneGroupMembers() {
2296 List<String> zoneGroupMembers = getZoneGroupMembers();
2297 zoneGroupMembers.remove(getUDN());
2298 return zoneGroupMembers;
2301 protected ZonePlayerHandler getHandlerByName(String remotePlayerName) throws IllegalStateException {
2302 for (ThingTypeUID supportedThingType : SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS) {
2303 Thing thing = localThingRegistry.get(new ThingUID(supportedThingType, remotePlayerName));
2304 if (thing != null) {
2305 ThingHandler handler = thing.getHandler();
2306 if (handler instanceof ZonePlayerHandler) {
2307 return (ZonePlayerHandler) handler;
2311 for (Thing aThing : localThingRegistry.getAll()) {
2312 if (SonosBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(aThing.getThingTypeUID())
2313 && aThing.getConfiguration().get(ZonePlayerConfiguration.UDN).equals(remotePlayerName)) {
2314 ThingHandler handler = aThing.getHandler();
2315 if (handler instanceof ZonePlayerHandler) {
2316 return (ZonePlayerHandler) handler;
2320 throw new IllegalStateException("Could not find handler for " + remotePlayerName);
2323 public void setMute(Command command) {
2324 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2325 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2326 || command.equals(OpenClosedType.OPEN)) ? "True" : "False";
2327 executeAction(SERVICE_RENDERING_CONTROL, ACTION_SET_MUTE,
2328 Map.of("Channel", "Master", "DesiredMute", value));
2332 public List<SonosAlarm> getCurrentAlarmList() {
2333 Map<String, String> result = executeAction(SERVICE_ALARM_CLOCK, "ListAlarms", null);
2334 String alarmList = result.get("CurrentAlarmList");
2335 return alarmList == null ? Collections.emptyList() : SonosXMLParser.getAlarmsFromStringResult(alarmList);
2338 public void updateAlarm(SonosAlarm alarm) {
2339 Map<String, String> inputs = new HashMap<>();
2342 inputs.put("ID", Integer.toString(alarm.getId()));
2343 inputs.put("StartLocalTime", alarm.getStartTime());
2344 inputs.put("Duration", alarm.getDuration());
2345 inputs.put("Recurrence", alarm.getRecurrence());
2346 inputs.put("RoomUUID", alarm.getRoomUUID());
2347 inputs.put("ProgramURI", alarm.getProgramURI());
2348 inputs.put("ProgramMetaData", alarm.getProgramMetaData());
2349 inputs.put("PlayMode", alarm.getPlayMode());
2350 inputs.put("Volume", Integer.toString(alarm.getVolume()));
2351 if (alarm.getIncludeLinkedZones()) {
2352 inputs.put("IncludeLinkedZones", "1");
2354 inputs.put("IncludeLinkedZones", "0");
2357 if (alarm.getEnabled()) {
2358 inputs.put("Enabled", "1");
2360 inputs.put("Enabled", "0");
2362 } catch (NumberFormatException ex) {
2363 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2366 executeAction(SERVICE_ALARM_CLOCK, "UpdateAlarm", inputs);
2369 public void setAlarm(Command command) {
2370 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2371 if (command.equals(OnOffType.ON) || command.equals(UpDownType.UP) || command.equals(OpenClosedType.OPEN)) {
2373 } else if (command.equals(OnOffType.OFF) || command.equals(UpDownType.DOWN)
2374 || command.equals(OpenClosedType.CLOSED)) {
2380 public void setAlarm(boolean alarmSwitch) {
2381 List<SonosAlarm> sonosAlarms = getCurrentAlarmList();
2383 // find the nearest alarm - take the current time from the Sonos system,
2384 // not the system where we are running
2385 SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
2386 fmt.setTimeZone(TimeZone.getTimeZone("GMT"));
2388 String currentLocalTime = getTime();
2389 Date currentDateTime = null;
2391 currentDateTime = fmt.parse(currentLocalTime);
2392 } catch (ParseException e) {
2393 logger.debug("An exception occurred while formatting a date", e);
2396 if (currentDateTime != null) {
2397 Calendar currentDateTimeCalendar = Calendar.getInstance();
2398 currentDateTimeCalendar.setTimeZone(TimeZone.getTimeZone("GMT"));
2399 currentDateTimeCalendar.setTime(currentDateTime);
2400 currentDateTimeCalendar.add(Calendar.DAY_OF_YEAR, 10);
2401 long shortestDuration = currentDateTimeCalendar.getTimeInMillis() - currentDateTime.getTime();
2403 SonosAlarm firstAlarm = null;
2405 for (SonosAlarm anAlarm : sonosAlarms) {
2406 SimpleDateFormat durationFormat = new SimpleDateFormat("HH:mm:ss");
2407 durationFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
2410 durationDate = durationFormat.parse(anAlarm.getDuration());
2411 } catch (ParseException e) {
2412 logger.debug("An exception occurred while parsing a date : '{}'", e.getMessage());
2416 long duration = durationDate.getTime();
2418 if (duration < shortestDuration && anAlarm.getRoomUUID().equals(getUDN())) {
2419 shortestDuration = duration;
2420 firstAlarm = anAlarm;
2425 if (firstAlarm != null) {
2427 firstAlarm.setEnabled(true);
2429 firstAlarm.setEnabled(false);
2432 updateAlarm(firstAlarm);
2437 public @Nullable String getTime() {
2439 return stateMap.get("CurrentLocalTime");
2442 public @Nullable String getAlarmRunning() {
2443 return stateMap.get("AlarmRunning");
2446 public boolean isAlarmRunning() {
2447 return "1".equals(getAlarmRunning());
2450 public void snoozeAlarm(Command command) {
2451 if (isAlarmRunning() && command instanceof DecimalType) {
2452 int minutes = ((DecimalType) command).intValue();
2454 Map<String, String> inputs = new HashMap<>();
2456 Calendar snoozePeriod = Calendar.getInstance();
2457 snoozePeriod.setTimeZone(TimeZone.getTimeZone("GMT"));
2458 snoozePeriod.setTimeInMillis(0);
2459 snoozePeriod.add(Calendar.MINUTE, minutes);
2460 SimpleDateFormat pFormatter = new SimpleDateFormat("HH:mm:ss");
2461 pFormatter.setTimeZone(TimeZone.getTimeZone("GMT"));
2464 inputs.put("Duration", pFormatter.format(snoozePeriod.getTime()));
2465 } catch (NumberFormatException ex) {
2466 logger.debug("Action Invalid Value Format Exception {}", ex.getMessage());
2469 executeAction(SERVICE_AV_TRANSPORT, ACTION_SNOOZE_ALARM, inputs);
2471 logger.debug("There is no alarm running on {}", getUDN());
2475 public @Nullable String getAnalogLineInConnected() {
2476 return stateMap.get(LINEINCONNECTED);
2479 public boolean isAnalogLineInConnected() {
2480 return "true".equals(getAnalogLineInConnected());
2483 public @Nullable String getOpticalLineInConnected() {
2484 return stateMap.get(TOSLINEINCONNECTED);
2487 public boolean isOpticalLineInConnected() {
2488 return "true".equals(getOpticalLineInConnected());
2491 public void becomeStandAlonePlayer() {
2492 executeAction(SERVICE_AV_TRANSPORT, ACTION_BECOME_COORDINATOR_OF_STANDALONE_GROUP, null);
2495 public void addMember(Command command) {
2496 if (command instanceof StringType) {
2497 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", GROUP_URI + getUDN());
2499 getHandlerByName(command.toString()).setCurrentURI(entry);
2500 } catch (IllegalStateException e) {
2501 logger.debug("Cannot add group member ({})", e.getMessage());
2506 @SuppressWarnings("PMD.CompareObjectsWithEquals")
2507 public boolean publicAddress(LineInType lineInType) {
2508 // check if sourcePlayer has a line-in connected
2509 if ((lineInType != LineInType.DIGITAL && isAnalogLineInConnected())
2510 || (lineInType != LineInType.ANALOG && isOpticalLineInConnected())) {
2511 // first remove this player from its own group if any
2512 becomeStandAlonePlayer();
2514 // add all other players to this new group
2515 for (SonosZoneGroup group : getZoneGroups()) {
2516 for (String player : group.getMembers()) {
2518 ZonePlayerHandler somePlayer = getHandlerByName(player);
2519 if (somePlayer != this) {
2520 somePlayer.becomeStandAlonePlayer();
2522 addMember(StringType.valueOf(somePlayer.getUDN()));
2524 } catch (IllegalStateException e) {
2525 logger.debug("Cannot add to group ({})", e.getMessage());
2531 ZonePlayerHandler coordinator = getCoordinatorHandler();
2532 // set the URI of the group to the line-in
2533 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "", ANALOG_LINE_IN_URI + getUDN());
2534 if (lineInType != LineInType.ANALOG && isOpticalLineInConnected()) {
2535 entry = new SonosEntry("", "", "", "", "", "", "", OPTICAL_LINE_IN_URI + getUDN() + SPDIF);
2537 coordinator.setCurrentURI(entry);
2541 } catch (IllegalStateException e) {
2542 logger.debug("Cannot handle command ({})", e.getMessage());
2546 logger.debug("Line-in of {} is not connected", getUDN());
2552 * Play a given url to music in one of the music libraries.
2555 * in the format of //host/folder/filename.mp3
2557 public void playURI(Command command) {
2558 if (command instanceof StringType) {
2560 String url = command.toString();
2562 ZonePlayerHandler coordinator = getCoordinatorHandler();
2564 // stop whatever is currently playing
2566 coordinator.waitForNotTransportState(STATE_PLAYING);
2568 // clear any tracks which are pending in the queue
2569 coordinator.removeAllTracksFromQueue();
2571 // add the new track we want to play to the queue
2572 // The url will be prefixed with x-file-cifs if it is NOT a http URL
2573 if (!url.startsWith("x-") && (!url.startsWith("http"))) {
2574 // default to file based url
2575 url = FILE_URI + url;
2577 coordinator.addURIToQueue(url, "", 0, true);
2579 // set the current playlist to our new queue
2580 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2582 // take the system off mute
2583 coordinator.setMute(OnOffType.OFF);
2587 } catch (IllegalStateException e) {
2588 logger.debug("Cannot play URI ({})", e.getMessage());
2589 } catch (InterruptedException e) {
2590 logger.debug("Play URI interrupted ({})", e.getMessage());
2591 Thread.currentThread().interrupt();
2596 private void scheduleNotificationSound(final Command command) {
2597 scheduler.submit(() -> {
2598 synchronized (notificationLock) {
2599 playNotificationSoundURI(command);
2605 * Play a given notification sound
2607 * @param url in the format of //host/folder/filename.mp3
2609 public void playNotificationSoundURI(Command notificationURL) {
2610 if (notificationURL instanceof StringType) {
2612 ZonePlayerHandler coordinator = getCoordinatorHandler();
2614 String currentURI = coordinator.getCurrentURI();
2615 logger.debug("playNotificationSoundURI: currentURI {} metadata {}", currentURI,
2616 coordinator.getCurrentURIMetadataAsString());
2618 if (isPlayingStreamOrRadio(currentURI)) {
2619 handleNotifForRadioStream(currentURI, notificationURL, coordinator);
2620 } else if (isPlayingLineIn(currentURI)) {
2621 handleNotifForLineIn(currentURI, notificationURL, coordinator);
2622 } else if (isPlayingVirtualLineIn(currentURI)) {
2623 handleNotifForVirtualLineIn(currentURI, notificationURL, coordinator);
2624 } else if (isPlayingQueue(currentURI)) {
2625 handleNotifForSharedQueue(currentURI, notificationURL, coordinator);
2626 } else if (isPlaylistEmpty(coordinator)) {
2627 handleNotifForEmptyQueue(notificationURL, coordinator);
2629 logger.debug("Notification feature not yet implemented while the current media is being played");
2631 synchronized (notificationLock) {
2632 notificationLock.notify();
2634 } catch (IllegalStateException e) {
2635 logger.debug("Cannot play notification sound ({})", e.getMessage());
2636 } catch (InterruptedException e) {
2637 logger.debug("Play notification sound interrupted ({})", e.getMessage());
2638 Thread.currentThread().interrupt();
2643 private boolean isPlaylistEmpty(ZonePlayerHandler coordinator) {
2644 return coordinator.getQueueSize() == 0;
2647 private boolean isPlayingQueue(@Nullable String currentURI) {
2648 return currentURI != null && currentURI.contains(QUEUE_URI);
2651 private boolean isPlayingStream(@Nullable String currentURI) {
2652 return currentURI != null && currentURI.contains(STREAM_URI);
2655 private boolean isPlayingRadio(@Nullable String currentURI) {
2656 // Google Play Music radio or Apple Music radio
2657 return currentURI != null && currentURI.contains(RADIO_URI);
2660 private boolean isPlayingRadioApp(@Nullable String currentURI) {
2661 // RadioApp music service
2662 return currentURI != null && currentURI.contains(RADIOAPP_URI);
2665 private boolean isPlayingRadioStartedByAmazonEcho(@Nullable String currentURI) {
2666 return currentURI != null && currentURI.contains(RADIO_MP3_URI) && currentURI.contains(OPML_TUNE);
2669 private boolean isPlayingStreamOrRadio(@Nullable String currentURI) {
2670 return isPlayingStream(currentURI) || isPlayingRadioStartedByAmazonEcho(currentURI)
2671 || isPlayingRadio(currentURI) || isPlayingRadioApp(currentURI);
2674 private boolean isPlayingLineIn(@Nullable String currentURI) {
2675 return currentURI != null && (isPlayingAnalogLineIn(currentURI) || isPlayingOpticalLineIn(currentURI));
2678 private boolean isPlayingAnalogLineIn(@Nullable String currentURI) {
2679 return currentURI != null && currentURI.contains(ANALOG_LINE_IN_URI);
2682 private boolean isPlayingOpticalLineIn(@Nullable String currentURI) {
2683 return currentURI != null && currentURI.startsWith(OPTICAL_LINE_IN_URI) && currentURI.endsWith(SPDIF);
2686 private boolean isPlayingVirtualLineIn(@Nullable String currentURI) {
2687 return currentURI != null && currentURI.startsWith(VIRTUAL_LINE_IN_URI);
2691 * Does a chain of predefined actions when a Notification sound is played by
2692 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2693 * radio streaming is currently loaded
2695 * @param currentStreamURI - the currently loaded stream's URI
2696 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2697 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2698 * @throws InterruptedException
2700 private void handleNotifForRadioStream(@Nullable String currentStreamURI, Command notificationURL,
2701 ZonePlayerHandler coordinator) throws InterruptedException {
2702 String nextAction = coordinator.getTransportState();
2703 SonosMetaData track = coordinator.getTrackMetadata();
2704 SonosMetaData currentUriMetaData = coordinator.getCurrentURIMetadata();
2706 handleNotificationSound(notificationURL, coordinator);
2707 if (currentStreamURI != null && track != null && currentUriMetaData != null) {
2708 coordinator.setCurrentURI(new SonosEntry("", currentUriMetaData.getTitle(), "", "", track.getAlbumArtUri(),
2709 "", currentUriMetaData.getUpnpClass(), currentStreamURI));
2710 restoreLastTransportState(coordinator, nextAction);
2715 * Does a chain of predefined actions when a Notification sound is played by
2716 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2717 * line in is currently loaded
2719 * @param currentLineInURI - the currently loaded line-in URI
2720 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2721 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2722 * @throws InterruptedException
2724 private void handleNotifForLineIn(@Nullable String currentLineInURI, Command notificationURL,
2725 ZonePlayerHandler coordinator) throws InterruptedException {
2726 logger.debug("Handling notification while sound from line-in was being played");
2727 String nextAction = coordinator.getTransportState();
2729 handleNotificationSound(notificationURL, coordinator);
2730 if (currentLineInURI != null) {
2731 logger.debug("Restoring sound from line-in using URI {}", currentLineInURI);
2732 coordinator.setCurrentURI(currentLineInURI, "");
2733 restoreLastTransportState(coordinator, nextAction);
2738 * Does a chain of predefined actions when a Notification sound is played by
2739 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2740 * virtual line in is currently loaded
2742 * @param currentVirtualLineInURI - the currently loaded virtual line-in URI
2743 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2744 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2745 * @throws InterruptedException
2747 private void handleNotifForVirtualLineIn(@Nullable String currentVirtualLineInURI, Command notificationURL,
2748 ZonePlayerHandler coordinator) throws InterruptedException {
2749 logger.debug("Handling notification while sound from virtual line-in was being played");
2750 String nextAction = coordinator.getTransportState();
2751 String currentUriMetaData = coordinator.getCurrentURIMetadataAsString();
2753 handleNotificationSound(notificationURL, coordinator);
2754 if (currentVirtualLineInURI != null && currentUriMetaData != null) {
2755 logger.debug("Restoring sound from virtual line-in using URI {} and metadata {}", currentVirtualLineInURI,
2756 currentUriMetaData);
2757 coordinator.setCurrentURI(currentVirtualLineInURI, currentUriMetaData);
2758 restoreLastTransportState(coordinator, nextAction);
2763 * Does a chain of predefined actions when a Notification sound is played by
2764 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2765 * shared queue is currently loaded
2767 * @param currentQueueURI - the currently loaded queue URI
2768 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2769 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2770 * @throws InterruptedException
2772 private void handleNotifForSharedQueue(@Nullable String currentQueueURI, Command notificationURL,
2773 ZonePlayerHandler coordinator) throws InterruptedException {
2774 String nextAction = coordinator.getTransportState();
2775 String trackPosition = coordinator.getRefreshedPosition();
2776 long currentTrackNumber = coordinator.getRefreshedCurrenTrackNr();
2778 "Handling notification while playing queue: currentQueueURI {} trackPosition {} currentTrackNumber {}",
2779 currentQueueURI, trackPosition, currentTrackNumber);
2781 handleNotificationSound(notificationURL, coordinator);
2782 String queueUri = QUEUE_URI + coordinator.getUDN() + "#0";
2783 if (queueUri.equals(currentQueueURI)) {
2784 coordinator.setPositionTrack(currentTrackNumber);
2785 coordinator.setPosition(trackPosition);
2786 restoreLastTransportState(coordinator, nextAction);
2791 * Handle the execution of the notification sound by sequentially executing the required steps.
2793 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2794 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2795 * @throws InterruptedException
2797 private void handleNotificationSound(Command notificationURL, ZonePlayerHandler coordinator)
2798 throws InterruptedException {
2799 boolean sourceStoppable = !isPlayingOpticalLineIn(coordinator.getCurrentURI());
2800 String originalVolume = (isAdHocGroup() || isStandalonePlayer()) ? getVolume() : coordinator.getVolume();
2801 if (sourceStoppable) {
2803 coordinator.waitForNotTransportState(STATE_PLAYING);
2804 applyNotificationSoundVolume();
2806 long notificationPosition = coordinator.getQueueSize() + 1;
2807 coordinator.addURIToQueue(notificationURL.toString(), "", notificationPosition, false);
2808 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2809 coordinator.setPositionTrack(notificationPosition);
2810 if (!sourceStoppable) {
2812 coordinator.waitForNotTransportState(STATE_PLAYING);
2813 applyNotificationSoundVolume();
2816 coordinator.waitForFinishedNotification();
2817 if (originalVolume != null) {
2818 setVolumeForGroup(DecimalType.valueOf(originalVolume));
2820 coordinator.removeRangeOfTracksFromQueue(new StringType(Long.toString(notificationPosition) + ",1"));
2823 private void restoreLastTransportState(ZonePlayerHandler coordinator, @Nullable String nextAction)
2824 throws InterruptedException {
2825 if (nextAction != null) {
2826 switch (nextAction) {
2829 coordinator.waitForTransportState(STATE_PLAYING);
2831 case STATE_PAUSED_PLAYBACK:
2832 coordinator.pause();
2839 * Does a chain of predefined actions when a Notification sound is played by
2840 * {@link ZonePlayerHandler#playNotificationSoundURI(Command)} in case
2841 * empty queue is currently loaded
2843 * @param notificationURL - the notification url in the format of //host/folder/filename.mp3
2844 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2845 * @throws InterruptedException
2847 private void handleNotifForEmptyQueue(Command notificationURL, ZonePlayerHandler coordinator)
2848 throws InterruptedException {
2849 String originalVolume = coordinator.getVolume();
2850 coordinator.applyNotificationSoundVolume();
2851 coordinator.playURI(notificationURL);
2852 coordinator.waitForFinishedNotification();
2853 coordinator.removeAllTracksFromQueue();
2854 if (originalVolume != null) {
2855 coordinator.setVolume(DecimalType.valueOf(originalVolume));
2860 * Applies the notification sound volume level to the group (if not null)
2862 * @param coordinator - {@link ZonePlayerHandler} coordinator for the SONOS device(s)
2864 private void applyNotificationSoundVolume() {
2865 setNotificationSoundVolume(getNotificationSoundVolume());
2868 private void waitForFinishedNotification() throws InterruptedException {
2869 waitForTransportState(STATE_PLAYING);
2871 // check Sonos state events to determine the end of the notification sound
2872 String notificationTitle = getCurrentTitle();
2873 long playstart = System.currentTimeMillis();
2874 while (System.currentTimeMillis() - playstart < (long) configuration.notificationTimeout * 1000) {
2876 String currentTitle = getCurrentTitle();
2877 if ((notificationTitle == null && currentTitle != null)
2878 || (notificationTitle != null && !notificationTitle.equals(currentTitle))
2879 || !STATE_PLAYING.equals(getTransportState())) {
2885 private void waitForTransportState(String state) throws InterruptedException {
2886 if (getTransportState() != null) {
2887 long start = System.currentTimeMillis();
2888 while (!state.equals(getTransportState())) {
2890 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2897 private void waitForNotTransportState(String state) throws InterruptedException {
2898 if (getTransportState() != null) {
2899 long start = System.currentTimeMillis();
2900 while (state.equals(getTransportState())) {
2902 if (System.currentTimeMillis() - start > (long) configuration.notificationTimeout * 1000) {
2910 * Removes a range of tracks from the queue.
2911 * (<x,y> will remove y songs started by the song number x)
2913 * @param command - must be in the format <startIndex, numberOfSongs>
2915 public void removeRangeOfTracksFromQueue(Command command) {
2916 if (command instanceof StringType) {
2917 String[] rangeInputSplit = command.toString().split(",");
2918 // If range input is incorrect, remove the first song by default
2919 String startIndex = rangeInputSplit[0] != null ? rangeInputSplit[0] : "1";
2920 String numberOfTracks = rangeInputSplit[1] != null ? rangeInputSplit[1] : "1";
2921 executeAction(SERVICE_AV_TRANSPORT, ACTION_REMOVE_TRACK_RANGE_FROM_QUEUE,
2922 Map.of("InstanceID", "0", "StartingIndex", startIndex, "NumberOfTracks", numberOfTracks));
2926 public void clearQueue() {
2928 ZonePlayerHandler coordinator = getCoordinatorHandler();
2930 coordinator.removeAllTracksFromQueue();
2931 } catch (IllegalStateException e) {
2932 logger.debug("Cannot clear queue ({})", e.getMessage());
2936 public void playQueue() {
2938 ZonePlayerHandler coordinator = getCoordinatorHandler();
2940 // set the current playlist to our new queue
2941 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
2943 // take the system off mute
2944 coordinator.setMute(OnOffType.OFF);
2948 } catch (IllegalStateException e) {
2949 logger.debug("Cannot play queue ({})", e.getMessage());
2953 public void setLed(Command command) {
2954 if (command instanceof OnOffType || command instanceof OpenClosedType || command instanceof UpDownType) {
2955 String value = (command.equals(OnOffType.ON) || command.equals(UpDownType.UP)
2956 || command.equals(OpenClosedType.OPEN)) ? "On" : "Off";
2957 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_SET_LED_STATE, Map.of("DesiredLEDState", value));
2958 executeAction(SERVICE_DEVICE_PROPERTIES, ACTION_GET_LED_STATE, null);
2962 public void removeMember(Command command) {
2963 if (command instanceof StringType) {
2965 ZonePlayerHandler oldmemberHandler = getHandlerByName(command.toString());
2967 oldmemberHandler.becomeStandAlonePlayer();
2968 SonosEntry entry = new SonosEntry("", "", "", "", "", "", "",
2969 QUEUE_URI + oldmemberHandler.getUDN() + "#0");
2970 oldmemberHandler.setCurrentURI(entry);
2971 } catch (IllegalStateException e) {
2972 logger.debug("Cannot remove group member ({})", e.getMessage());
2977 public void previous() {
2978 executeAction(SERVICE_AV_TRANSPORT, ACTION_PREVIOUS, null);
2981 public void next() {
2982 executeAction(SERVICE_AV_TRANSPORT, ACTION_NEXT, null);
2985 public void stopPlaying(Command command) {
2986 if (command instanceof OnOffType) {
2988 getCoordinatorHandler().stop();
2989 } catch (IllegalStateException e) {
2990 logger.debug("Cannot handle stop command ({})", e.getMessage(), e);
2995 public void playRadio(Command command) {
2996 if (command instanceof StringType) {
2997 String station = command.toString();
2998 List<SonosEntry> stations = getFavoriteRadios();
3000 SonosEntry theEntry = null;
3001 // search for the appropriate radio based on its name (title)
3002 for (SonosEntry someStation : stations) {
3003 if (someStation.getTitle().equals(station)) {
3004 theEntry = someStation;
3009 // set the URI of the group coordinator
3010 if (theEntry != null) {
3012 ZonePlayerHandler coordinator = getCoordinatorHandler();
3013 coordinator.setCurrentURI(theEntry);
3015 } catch (IllegalStateException e) {
3016 logger.debug("Cannot play radio ({})", e.getMessage());
3019 logger.debug("Radio station '{}' not found", station);
3024 public void playTuneinStation(Command command) {
3025 if (command instanceof StringType) {
3026 String stationId = command.toString();
3027 List<SonosMusicService> allServices = getAvailableMusicServices();
3029 SonosMusicService tuneinService = null;
3030 // search for the TuneIn music service based on its name
3031 if (allServices != null) {
3032 for (SonosMusicService service : allServices) {
3033 if ("TuneIn".equals(service.getName())) {
3034 tuneinService = service;
3040 // set the URI of the group coordinator
3041 if (tuneinService != null) {
3043 ZonePlayerHandler coordinator = getCoordinatorHandler();
3044 SonosEntry entry = new SonosEntry("", "TuneIn station", "", "", "", "",
3045 "object.item.audioItem.audioBroadcast",
3046 String.format(TUNEIN_URI, stationId, tuneinService.getId()));
3047 Integer tuneinServiceType = tuneinService.getType();
3048 int serviceTypeNum = tuneinServiceType == null ? TUNEIN_DEFAULT_SERVICE_TYPE : tuneinServiceType;
3049 entry.setDesc("SA_RINCON" + Integer.toString(serviceTypeNum) + "_");
3050 coordinator.setCurrentURI(entry);
3052 } catch (IllegalStateException e) {
3053 logger.debug("Cannot play TuneIn station {} ({})", stationId, e.getMessage());
3056 logger.debug("TuneIn service not found");
3061 private @Nullable List<SonosMusicService> getAvailableMusicServices() {
3062 if (musicServices == null) {
3063 Map<String, String> result = service.invokeAction(this, "MusicServices", "ListAvailableServices", null);
3065 String serviceList = result.get("AvailableServiceDescriptorList");
3066 if (serviceList != null) {
3067 List<SonosMusicService> services = SonosXMLParser.getMusicServicesFromXML(serviceList);
3068 musicServices = services;
3070 String[] servicesTypes = new String[0];
3071 String serviceTypeList = result.get("AvailableServiceTypeList");
3072 if (serviceTypeList != null) {
3073 // It is a comma separated list of service types (integers) in the same order as the services
3074 // declaration in "AvailableServiceDescriptorList" except that there is no service type for the
3076 servicesTypes = serviceTypeList.split(",");
3080 for (SonosMusicService service : services) {
3081 if (!"TuneIn".equals(service.getName())) {
3082 // Add the service type integer value from "AvailableServiceTypeList" to each service
3084 if (idx < servicesTypes.length) {
3086 Integer serviceType = Integer.parseInt(servicesTypes[idx]);
3087 service.setType(serviceType);
3088 } catch (NumberFormatException e) {
3093 service.setType(TUNEIN_DEFAULT_SERVICE_TYPE);
3095 logger.debug("Service name {} => id {} type {}", service.getName(), service.getId(),
3100 return musicServices;
3104 * This will attempt to match the station string with a entry in the
3105 * favorites list, this supports both single entries and playlists
3107 * @param favorite to match
3108 * @return true if a match was found and played.
3110 public void playFavorite(Command command) {
3111 if (command instanceof StringType) {
3112 String favorite = command.toString();
3113 List<SonosEntry> favorites = getFavorites();
3115 SonosEntry theEntry = null;
3116 // search for the appropriate favorite based on its name (title)
3117 for (SonosEntry entry : favorites) {
3118 if (entry.getTitle().equals(favorite)) {
3124 // set the URI of the group coordinator
3125 if (theEntry != null) {
3127 ZonePlayerHandler coordinator = getCoordinatorHandler();
3130 * If this is a playlist we need to treat it as such
3132 SonosResourceMetaData resourceMetaData = theEntry.getResourceMetaData();
3133 if (resourceMetaData != null && resourceMetaData.getUpnpClass().startsWith("object.container")) {
3134 coordinator.removeAllTracksFromQueue();
3135 coordinator.addURIToQueue(theEntry);
3136 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3137 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3138 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3140 coordinator.setCurrentURI(theEntry);
3143 } catch (IllegalStateException e) {
3144 logger.debug("Cannot paly favorite ({})", e.getMessage());
3147 logger.debug("Favorite '{}' not found", favorite);
3152 public void playTrack(Command command) {
3153 if (command instanceof DecimalType) {
3155 ZonePlayerHandler coordinator = getCoordinatorHandler();
3157 String trackNumber = String.valueOf(((DecimalType) command).intValue());
3159 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3161 // seek the track - warning, we do not check if the tracknumber falls in the boundary of the queue
3162 coordinator.setPositionTrack(trackNumber);
3164 // take the system off mute
3165 coordinator.setMute(OnOffType.OFF);
3169 } catch (IllegalStateException e) {
3170 logger.debug("Cannot play track ({})", e.getMessage());
3175 public void playPlayList(Command command) {
3176 if (command instanceof StringType) {
3177 String playlist = command.toString();
3178 List<SonosEntry> playlists = getPlayLists();
3180 SonosEntry theEntry = null;
3181 // search for the appropriate play list based on its name (title)
3182 for (SonosEntry somePlaylist : playlists) {
3183 if (somePlaylist.getTitle().equals(playlist)) {
3184 theEntry = somePlaylist;
3189 // set the URI of the group coordinator
3190 if (theEntry != null) {
3192 ZonePlayerHandler coordinator = getCoordinatorHandler();
3194 coordinator.addURIToQueue(theEntry);
3196 coordinator.setCurrentURI(QUEUE_URI + coordinator.getUDN() + "#0", "");
3198 String firstTrackNumberEnqueued = stateMap.get("FirstTrackNumberEnqueued");
3199 coordinator.seek("TRACK_NR", firstTrackNumberEnqueued);
3202 } catch (IllegalStateException e) {
3203 logger.debug("Cannot play playlist ({})", e.getMessage());
3206 logger.debug("Playlist '{}' not found", playlist);
3211 public void addURIToQueue(SonosEntry newEntry) {
3212 addURIToQueue(newEntry.getRes(), SonosXMLParser.compileMetadataString(newEntry), 1, true);
3215 public @Nullable String getZoneName() {
3216 return stateMap.get("ZoneName");
3219 public @Nullable String getZoneGroupID() {
3220 return stateMap.get("LocalGroupUUID");
3223 public @Nullable String getRunningAlarmProperties() {
3224 return stateMap.get("RunningAlarmProperties");
3227 public @Nullable String getRefreshedRunningAlarmProperties() {
3228 updateRunningAlarmProperties();
3229 return getRunningAlarmProperties();
3232 public @Nullable String getMute() {
3233 return stateMap.get("MuteMaster");
3236 public @Nullable String getLed() {
3237 return stateMap.get("CurrentLEDState");
3240 public @Nullable String getCurrentZoneName() {
3241 return stateMap.get("CurrentZoneName");
3244 public @Nullable String getRefreshedCurrentZoneName() {
3245 updateCurrentZoneName();
3246 return getCurrentZoneName();
3250 public void onStatusChanged(boolean status) {
3252 logger.info("UPnP device {} is present (thing {})", getUDN(), getThing().getUID());
3253 if (getThing().getStatus() != ThingStatus.ONLINE) {
3254 updateStatus(ThingStatus.ONLINE);
3255 scheduler.execute(this::poll);
3258 logger.info("UPnP device {} is absent (thing {})", getUDN(), getThing().getUID());
3259 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
3263 private @Nullable String getModelNameFromDescriptor() {
3264 URL descriptor = service.getDescriptorURL(this);
3265 if (descriptor != null) {
3266 String sonosModelDescription = SonosXMLParser.parseModelDescription(descriptor);
3267 return sonosModelDescription == null ? null
3268 : SonosXMLParser.buildThingTypeIdFromModelName(sonosModelDescription);
3274 private boolean migrateThingType() {
3275 if (getThing().getThingTypeUID().equals(ZONEPLAYER_THING_TYPE_UID)) {
3276 String modelName = getModelNameFromDescriptor();
3277 if (modelName != null && isSupportedModel(modelName)) {
3278 updateSonosThingType(modelName);
3285 private boolean isSupportedModel(String modelName) {
3286 for (ThingTypeUID thingTypeUID : SUPPORTED_KNOWN_THING_TYPES_UIDS) {
3287 if (thingTypeUID.getId().equalsIgnoreCase(modelName)) {
3294 private void updateSonosThingType(String newThingTypeID) {
3295 changeThingType(new ThingTypeUID(SonosBindingConstants.BINDING_ID, newThingTypeID), getConfig());
3299 * Set the sleeptimer duration
3300 * Use String command of format "HH:MM:SS" to set the timer to the desired duration
3301 * Use empty String "" to switch the sleep timer off
3303 public void setSleepTimer(Command command) {
3304 if (command instanceof DecimalType) {
3305 this.service.invokeAction(this, SERVICE_AV_TRANSPORT, ACTION_CONFIGURE_SLEEP_TIMER, Map.of("InstanceID",
3306 "0", "NewSleepTimerDuration", sleepSecondsToTimeStr(((DecimalType) command).longValue())));
3310 protected void updateSleepTimerDuration() {
3311 executeAction(SERVICE_AV_TRANSPORT, ACTION_GET_REMAINING_SLEEP_TIMER_DURATION, null);
3314 private String sleepSecondsToTimeStr(long sleepSeconds) {
3315 if (sleepSeconds == 0) {
3317 } else if (sleepSeconds < 68400) {
3318 long remainingSeconds = sleepSeconds;
3319 long hours = TimeUnit.SECONDS.toHours(remainingSeconds);
3320 remainingSeconds -= TimeUnit.HOURS.toSeconds(hours);
3321 long minutes = TimeUnit.SECONDS.toMinutes(remainingSeconds);
3322 remainingSeconds -= TimeUnit.MINUTES.toSeconds(minutes);
3323 long seconds = TimeUnit.SECONDS.toSeconds(remainingSeconds);
3324 return String.format("%02d:%02d:%02d", hours, minutes, seconds);
3326 logger.debug("Sonos SleepTimer: Invalid sleep time set. sleep time must be >=0 and < 68400s (24h)");
3331 private long sleepStrTimeToSeconds(String sleepTime) {
3332 String[] units = sleepTime.split(":");
3333 int hours = Integer.parseInt(units[0]);
3334 int minutes = Integer.parseInt(units[1]);
3335 int seconds = Integer.parseInt(units[2]);
3336 return 3600 * hours + 60 * minutes + seconds;
3339 private @Nullable String extractInfoFromMoreInfo(String searchedInfo) {
3340 String value = stateMap.get("MoreInfo");
3341 if (value != null) {
3342 String[] fields = value.split(",");
3343 for (int i = 0; i < fields.length; i++) {
3344 String[] pair = fields[i].trim().split(":");
3345 if (pair.length == 2 && searchedInfo.equalsIgnoreCase(pair[0].trim())) {
3346 return pair[1].trim();