2 * Copyright (c) 2010-2021 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.squeezebox.internal.handler;
15 import static org.openhab.binding.squeezebox.internal.SqueezeBoxBindingConstants.*;
18 import java.net.URISyntaxException;
19 import java.time.Duration;
20 import java.util.ArrayList;
21 import java.util.Collections;
22 import java.util.HashMap;
23 import java.util.List;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import org.apache.commons.lang.StringUtils;
30 import org.eclipse.jdt.annotation.NonNull;
31 import org.openhab.binding.squeezebox.internal.SqueezeBoxStateDescriptionOptionsProvider;
32 import org.openhab.binding.squeezebox.internal.config.SqueezeBoxPlayerConfig;
33 import org.openhab.binding.squeezebox.internal.model.Favorite;
34 import org.openhab.binding.squeezebox.internal.utils.SqueezeBoxTimeoutException;
35 import org.openhab.core.cache.ExpiringCacheMap;
36 import org.openhab.core.io.net.http.HttpUtil;
37 import org.openhab.core.library.types.DecimalType;
38 import org.openhab.core.library.types.IncreaseDecreaseType;
39 import org.openhab.core.library.types.NextPreviousType;
40 import org.openhab.core.library.types.OnOffType;
41 import org.openhab.core.library.types.PercentType;
42 import org.openhab.core.library.types.PlayPauseType;
43 import org.openhab.core.library.types.RawType;
44 import org.openhab.core.library.types.RewindFastforwardType;
45 import org.openhab.core.library.types.StringType;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.ThingStatusInfo;
51 import org.openhab.core.thing.ThingTypeUID;
52 import org.openhab.core.thing.binding.BaseThingHandler;
53 import org.openhab.core.types.Command;
54 import org.openhab.core.types.RefreshType;
55 import org.openhab.core.types.State;
56 import org.openhab.core.types.StateOption;
57 import org.openhab.core.types.UnDefType;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
62 * The {@link SqueezeBoxPlayerHandler} is responsible for handling states, which
63 * are sent to/from channels.
65 * @author Dan Cunningham - Initial contribution
66 * @author Mark Hilbush - Improved handling of player status, prevent REFRESH from causing exception
67 * @author Mark Hilbush - Implement AudioSink and notifications
68 * @author Mark Hilbush - Added duration channel
69 * @author Patrik Gfeller - Timeout for TTS messages increased from 30 to 90s.
70 * @author Mark Hilbush - Get favorites from server and play favorite
71 * @author Mark Hilbush - Convert sound notification volume from channel to config parameter
72 * @author Mark Hilbush - Add like/unlike functionality
74 public class SqueezeBoxPlayerHandler extends BaseThingHandler implements SqueezeBoxPlayerEventListener {
75 private final Logger logger = LoggerFactory.getLogger(SqueezeBoxPlayerHandler.class);
77 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
78 .singleton(SQUEEZEBOXPLAYER_THING_TYPE);
81 * We need to remember some states to change offsets in volume, time index,
84 protected Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
87 * Keeps current track time
89 private ScheduledFuture<?> timeCounterJob;
92 * Local reference to our bridge
94 private SqueezeBoxServerHandler squeezeBoxServerHandler;
97 * Our mac address, needed everywhere
102 * The server sends us the current time on play/pause/stop events, we
103 * increment it locally from there on
105 private int currentTime = 0;
108 * Our we playing something right now or not, need to keep current track
111 private boolean playing;
114 * Separate volume level for notifications
116 private Integer notificationSoundVolume = null;
118 private String callbackUrl;
120 private SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider;
122 private static final ExpiringCacheMap<String, RawType> IMAGE_CACHE = new ExpiringCacheMap<>(
123 TimeUnit.MINUTES.toMillis(15)); // 15min
125 private String likeCommand;
126 private String unlikeCommand;
129 * Creates SqueezeBox Player Handler
132 * @param stateDescriptionProvider
134 public SqueezeBoxPlayerHandler(@NonNull Thing thing, String callbackUrl,
135 SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider) {
137 this.callbackUrl = callbackUrl;
138 this.stateDescriptionProvider = stateDescriptionProvider;
142 public void initialize() {
143 mac = getConfig().as(SqueezeBoxPlayerConfig.class).mac;
145 updateBridgeStatus();
146 logger.debug("player thing {} initialized with mac {}", getThing().getUID(), mac);
150 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
151 updateBridgeStatus();
154 private void updateBridgeStatus() {
155 Thing bridge = getBridge();
156 if (bridge != null) {
157 squeezeBoxServerHandler = (SqueezeBoxServerHandler) bridge.getHandler();
158 ThingStatus bridgeStatus = bridge.getStatus();
159 if (bridgeStatus == ThingStatus.ONLINE && getThing().getStatus() != ThingStatus.ONLINE) {
160 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
161 } else if (bridgeStatus == ThingStatus.OFFLINE) {
162 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
165 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not found");
170 public void dispose() {
171 // stop our duration counter
172 if (timeCounterJob != null && !timeCounterJob.isCancelled()) {
173 timeCounterJob.cancel(true);
174 timeCounterJob = null;
177 if (squeezeBoxServerHandler != null) {
178 squeezeBoxServerHandler.removePlayerCache(mac);
180 logger.debug("player thing {} disposed for mac {}", getThing().getUID(), mac);
185 public void handleCommand(ChannelUID channelUID, Command command) {
186 if (squeezeBoxServerHandler == null) {
187 logger.debug("Player {} has no server configured, ignoring command: {}", getThing().getUID(), command);
190 // Some of the code below is not designed to handle REFRESH, only reply to channels where cached values exist
191 if (command == RefreshType.REFRESH) {
192 String channelID = channelUID.getId();
193 State newState = stateMap.get(channelID);
194 if (newState != null) {
195 updateState(channelID, newState);
200 switch (channelUID.getIdWithoutGroup()) {
202 if (command.equals(OnOffType.ON)) {
203 squeezeBoxServerHandler.powerOn(mac);
205 squeezeBoxServerHandler.powerOff(mac);
209 if (command.equals(OnOffType.ON)) {
210 squeezeBoxServerHandler.mute(mac);
212 squeezeBoxServerHandler.unMute(mac);
216 if (command.equals(OnOffType.ON)) {
217 squeezeBoxServerHandler.stop(mac);
218 } else if (command.equals(OnOffType.OFF)) {
219 squeezeBoxServerHandler.play(mac);
222 case CHANNEL_PLAY_PAUSE:
223 if (command.equals(OnOffType.ON)) {
224 squeezeBoxServerHandler.play(mac);
225 } else if (command.equals(OnOffType.OFF)) {
226 squeezeBoxServerHandler.pause(mac);
230 if (command.equals(OnOffType.ON)) {
231 squeezeBoxServerHandler.prev(mac);
235 if (command.equals(OnOffType.ON)) {
236 squeezeBoxServerHandler.next(mac);
240 if (command instanceof PercentType) {
241 squeezeBoxServerHandler.setVolume(mac, ((PercentType) command).intValue());
242 } else if (command.equals(IncreaseDecreaseType.INCREASE)) {
243 squeezeBoxServerHandler.volumeUp(mac, currentVolume());
244 } else if (command.equals(IncreaseDecreaseType.DECREASE)) {
245 squeezeBoxServerHandler.volumeDown(mac, currentVolume());
246 } else if (command.equals(OnOffType.OFF)) {
247 squeezeBoxServerHandler.mute(mac);
248 } else if (command.equals(OnOffType.ON)) {
249 squeezeBoxServerHandler.unMute(mac);
252 case CHANNEL_CONTROL:
253 if (command instanceof PlayPauseType) {
254 if (command.equals(PlayPauseType.PLAY)) {
255 squeezeBoxServerHandler.play(mac);
256 } else if (command.equals(PlayPauseType.PAUSE)) {
257 squeezeBoxServerHandler.pause(mac);
260 if (command instanceof NextPreviousType) {
261 if (command.equals(NextPreviousType.NEXT)) {
262 squeezeBoxServerHandler.next(mac);
263 } else if (command.equals(NextPreviousType.PREVIOUS)) {
264 squeezeBoxServerHandler.prev(mac);
267 if (command instanceof RewindFastforwardType) {
268 if (command.equals(RewindFastforwardType.REWIND)) {
269 squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() - 5);
270 } else if (command.equals(RewindFastforwardType.FASTFORWARD)) {
271 squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() + 5);
276 squeezeBoxServerHandler.playUrl(mac, command.toString());
279 if (StringUtils.isBlank(command.toString())) {
280 squeezeBoxServerHandler.unSyncPlayer(mac);
282 squeezeBoxServerHandler.syncPlayer(mac, command.toString());
286 if (command.equals(OnOffType.ON)) {
287 squeezeBoxServerHandler.unSyncPlayer(mac);
290 case CHANNEL_PLAYLIST_INDEX:
291 squeezeBoxServerHandler.playPlaylistItem(mac, ((DecimalType) command).intValue());
293 case CHANNEL_CURRENT_PLAYING_TIME:
294 squeezeBoxServerHandler.setPlayingTime(mac, ((DecimalType) command).intValue());
296 case CHANNEL_CURRENT_PLAYLIST_SHUFFLE:
297 squeezeBoxServerHandler.setShuffleMode(mac, ((DecimalType) command).intValue());
299 case CHANNEL_CURRENT_PLAYLIST_REPEAT:
300 squeezeBoxServerHandler.setRepeatMode(mac, ((DecimalType) command).intValue());
302 case CHANNEL_FAVORITES_PLAY:
303 squeezeBoxServerHandler.playFavorite(mac, command.toString());
306 if (command.equals(OnOffType.ON)) {
307 squeezeBoxServerHandler.rate(mac, likeCommand);
308 } else if (command.equals(OnOffType.OFF)) {
309 squeezeBoxServerHandler.rate(mac, unlikeCommand);
313 if (command instanceof DecimalType) {
314 Duration sleepDuration = Duration.ofMinutes(((DecimalType) command).longValue());
315 if (sleepDuration.isNegative() || sleepDuration.compareTo(Duration.ofDays(1)) > 0) {
316 logger.debug("Sleep timer of {} minutes must be >= 0 and <= 1 day", sleepDuration.toMinutes());
319 squeezeBoxServerHandler.sleep(mac, sleepDuration);
328 public void playerAdded(SqueezeBoxPlayer player) {
329 // Player properties are saved in SqueezeBoxPlayerDiscoveryParticipant
333 public void powerChangeEvent(String mac, boolean power) {
334 updateChannel(mac, CHANNEL_POWER, power ? OnOffType.ON : OnOffType.OFF);
335 if (!power && isMe(mac)) {
341 public synchronized void modeChangeEvent(String mac, String mode) {
342 updateChannel(mac, CHANNEL_CONTROL, "play".equals(mode) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
343 updateChannel(mac, CHANNEL_PLAY_PAUSE, "play".equals(mode) ? OnOffType.ON : OnOffType.OFF);
344 updateChannel(mac, CHANNEL_STOP, "stop".equals(mode) ? OnOffType.ON : OnOffType.OFF);
346 playing = "play".equalsIgnoreCase(mode);
351 public void sourceChangeEvent(String mac, String source) {
352 updateChannel(mac, CHANNEL_SOURCE, StringType.valueOf(source));
356 public void absoluteVolumeChangeEvent(String mac, int volume) {
357 int newVolume = volume;
358 newVolume = Math.min(100, newVolume);
359 newVolume = Math.max(0, newVolume);
360 updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
364 public void relativeVolumeChangeEvent(String mac, int volumeChange) {
365 int newVolume = currentVolume() + volumeChange;
366 newVolume = Math.min(100, newVolume);
367 newVolume = Math.max(0, newVolume);
368 updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
371 logger.trace("Volume changed [{}] for player {}. New volume: {}", volumeChange, mac, newVolume);
376 public void muteChangeEvent(String mac, boolean mute) {
377 updateChannel(mac, CHANNEL_MUTE, mute ? OnOffType.ON : OnOffType.OFF);
381 public void currentPlaylistIndexEvent(String mac, int index) {
382 updateChannel(mac, CHANNEL_PLAYLIST_INDEX, new DecimalType(index));
386 public void currentPlayingTimeEvent(String mac, int time) {
387 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new DecimalType(time));
394 public void durationEvent(String mac, int duration) {
395 if (getThing().getChannel(CHANNEL_DURATION) == null) {
396 logger.debug("Channel 'duration' does not exist. Delete and readd player thing to pick up channel.");
399 updateChannel(mac, CHANNEL_DURATION, new DecimalType(duration));
403 public void numberPlaylistTracksEvent(String mac, int track) {
404 updateChannel(mac, CHANNEL_NUMBER_PLAYLIST_TRACKS, new DecimalType(track));
408 public void currentPlaylistShuffleEvent(String mac, int shuffle) {
409 updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_SHUFFLE, new DecimalType(shuffle));
413 public void currentPlaylistRepeatEvent(String mac, int repeat) {
414 updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_REPEAT, new DecimalType(repeat));
418 public void titleChangeEvent(String mac, String title) {
419 updateChannel(mac, CHANNEL_TITLE, new StringType(title));
423 public void albumChangeEvent(String mac, String album) {
424 updateChannel(mac, CHANNEL_ALBUM, new StringType(album));
428 public void artistChangeEvent(String mac, String artist) {
429 updateChannel(mac, CHANNEL_ARTIST, new StringType(artist));
433 public void coverArtChangeEvent(String mac, String coverArtUrl) {
434 updateChannel(mac, CHANNEL_COVERART_DATA, createImage(downloadImage(mac, coverArtUrl)));
438 * Download and cache the image data from an URL.
440 * @param url The URL of the image to be downloaded.
441 * @return A RawType object containing the image, null if the content type could not be found or the content type is
444 private RawType downloadImage(String mac, String url) {
445 // Only get the image if this is my PlayerHandler instance
447 if (StringUtils.isNotEmpty(url)) {
448 String sanitizedUrl = sanitizeUrl(url);
449 RawType image = IMAGE_CACHE.putIfAbsentAndGet(url, () -> {
450 logger.debug("Trying to download the content of URL {}", sanitizedUrl);
452 return HttpUtil.downloadImage(url);
453 } catch (IllegalArgumentException e) {
454 logger.debug("IllegalArgumentException when downloading image from {}", sanitizedUrl, e);
459 logger.debug("Failed to download the content of URL {}", sanitizedUrl);
470 * Replaces the password in the URL, if present
472 private String sanitizeUrl(String url) {
473 String sanitizedUrl = url;
475 URI uri = new URI(url);
476 String userInfo = uri.getUserInfo();
477 if (userInfo != null) {
478 String[] userInfoParts = userInfo.split(":");
479 if (userInfoParts.length == 2) {
480 sanitizedUrl = url.replace(userInfoParts[1], "**********");
483 } catch (URISyntaxException e) {
484 // Just return what was passed in
490 * Wrap the given RawType and return it as {@link State} or return {@link UnDefType#UNDEF} if the RawType is null.
492 private State createImage(RawType image) {
494 return UnDefType.UNDEF;
501 public void yearChangeEvent(String mac, String year) {
502 updateChannel(mac, CHANNEL_YEAR, new StringType(year));
506 public void genreChangeEvent(String mac, String genre) {
507 updateChannel(mac, CHANNEL_GENRE, new StringType(genre));
511 public void remoteTitleChangeEvent(String mac, String title) {
512 updateChannel(mac, CHANNEL_REMOTE_TITLE, new StringType(title));
516 public void irCodeChangeEvent(String mac, String ircode) {
518 postCommand(CHANNEL_IRCODE, new StringType(ircode));
523 public void buttonsChangeEvent(String mac, String likeCommand, String unlikeCommand) {
525 this.likeCommand = likeCommand;
526 this.unlikeCommand = unlikeCommand;
527 logger.trace("Player {} got a button change event: like='{}' unlike='{}'", mac, likeCommand, unlikeCommand);
532 public void updateFavoritesListEvent(List<Favorite> favorites) {
533 logger.trace("Player {} updating favorites list with {} favorites", mac, favorites.size());
534 List<StateOption> options = new ArrayList<>();
535 for (Favorite favorite : favorites) {
536 options.add(new StateOption(favorite.shortId, favorite.name));
538 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_FAVORITES_PLAY), options);
542 * Update a channel if the mac matches our own
548 private void updateChannel(String mac, String channelID, State state) {
550 State prevState = stateMap.put(channelID, state);
551 if (prevState == null || !prevState.equals(state)) {
552 logger.trace("Updating channel {} for thing {} with mac {} to state {}", channelID, getThing().getUID(),
554 updateState(channelID, state);
560 * Helper methods to get the current state of the player
564 int currentVolume() {
565 if (stateMap.containsKey(CHANNEL_VOLUME)) {
566 return ((DecimalType) stateMap.get(CHANNEL_VOLUME)).intValue();
572 int currentPlayingTime() {
573 if (stateMap.containsKey(CHANNEL_CURRENT_PLAYING_TIME)) {
574 return ((DecimalType) stateMap.get(CHANNEL_CURRENT_PLAYING_TIME)).intValue();
580 int currentNumberPlaylistTracks() {
581 if (stateMap.containsKey(CHANNEL_NUMBER_PLAYLIST_TRACKS)) {
582 return ((DecimalType) stateMap.get(CHANNEL_NUMBER_PLAYLIST_TRACKS)).intValue();
588 int currentPlaylistIndex() {
589 if (stateMap.containsKey(CHANNEL_PLAYLIST_INDEX)) {
590 return ((DecimalType) stateMap.get(CHANNEL_PLAYLIST_INDEX)).intValue();
596 boolean currentPower() {
597 if (stateMap.containsKey(CHANNEL_POWER)) {
598 return (stateMap.get(CHANNEL_POWER).equals(OnOffType.ON) ? true : false);
604 boolean currentStop() {
605 if (stateMap.containsKey(CHANNEL_STOP)) {
606 return (stateMap.get(CHANNEL_STOP).equals(OnOffType.ON) ? true : false);
612 boolean currentControl() {
613 if (stateMap.containsKey(CHANNEL_CONTROL)) {
614 return (stateMap.get(CHANNEL_CONTROL).equals(PlayPauseType.PLAY) ? true : false);
620 boolean currentMute() {
621 if (stateMap.containsKey(CHANNEL_MUTE)) {
622 return (stateMap.get(CHANNEL_MUTE).equals(OnOffType.ON) ? true : false);
628 int currentShuffle() {
629 if (stateMap.containsKey(CHANNEL_CURRENT_PLAYLIST_SHUFFLE)) {
630 return ((DecimalType) stateMap.get(CHANNEL_CURRENT_PLAYLIST_SHUFFLE)).intValue();
636 int currentRepeat() {
637 if (stateMap.containsKey(CHANNEL_CURRENT_PLAYLIST_REPEAT)) {
638 return ((DecimalType) stateMap.get(CHANNEL_CURRENT_PLAYLIST_REPEAT)).intValue();
645 * Ticks away when in a play state to keep current track time
647 private void timeCounter() {
648 timeCounterJob = scheduler.scheduleWithFixedDelay(() -> {
650 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new DecimalType(currentTime++));
652 }, 0, 1, TimeUnit.SECONDS);
655 private boolean isMe(String mac) {
656 return mac.equals(this.mac);
660 * Returns our server handler if set
664 public SqueezeBoxServerHandler getSqueezeBoxServerHandler() {
665 return this.squeezeBoxServerHandler;
669 * Returns the MAC address for this player
673 public String getMac() {
678 * Give the notification player access to the notification timeout
680 public int getNotificationTimeout() {
681 return getConfigAs(SqueezeBoxPlayerConfig.class).notificationTimeout;
685 * Used by the AudioSink to get the volume level that should be used for the notification.
686 * Priority for determining volume is:
687 * - volume is provided in the say/playSound actions
688 * - volume is contained in the player thing's configuration
689 * - current player volume setting
691 public PercentType getNotificationSoundVolume() {
692 // Get the notification sound volume from this player thing's configuration
693 Integer configNotificationSoundVolume = getConfigAs(SqueezeBoxPlayerConfig.class).notificationVolume;
695 // Determine which volume to use
696 Integer currentNotificationSoundVolume;
697 if (notificationSoundVolume != null) {
698 currentNotificationSoundVolume = notificationSoundVolume;
699 } else if (configNotificationSoundVolume != null) {
700 currentNotificationSoundVolume = configNotificationSoundVolume;
702 currentNotificationSoundVolume = Integer.valueOf(currentVolume());
704 return new PercentType(currentNotificationSoundVolume.intValue());
708 * Used by the AudioSink to set the volume level that should be used to play the notification
710 public void setNotificationSoundVolume(PercentType newNotificationSoundVolume) {
711 if (newNotificationSoundVolume != null) {
712 notificationSoundVolume = Integer.valueOf(newNotificationSoundVolume.intValue());
717 * Play the notification.
719 public void playNotificationSoundURI(StringType uri) {
720 logger.debug("Play notification sound on player {} at URI {}", mac, uri);
722 try (SqueezeBoxNotificationPlayer notificationPlayer = new SqueezeBoxNotificationPlayer(this,
723 squeezeBoxServerHandler, uri)) {
724 notificationPlayer.play();
725 } catch (InterruptedException e) {
726 logger.warn("Notification playback was interrupted", e);
727 } catch (SqueezeBoxTimeoutException e) {
728 logger.debug("SqueezeBoxTimeoutException during notification: {}", e.getMessage());
730 notificationSoundVolume = null;
735 * Return the IP and port of the OH2 web server
737 public String getHostAndPort() {