2 * Copyright (c) 2010-2023 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.eclipse.jdt.annotation.NonNull;
30 import org.openhab.binding.squeezebox.internal.SqueezeBoxStateDescriptionOptionsProvider;
31 import org.openhab.binding.squeezebox.internal.config.SqueezeBoxPlayerConfig;
32 import org.openhab.binding.squeezebox.internal.model.Favorite;
33 import org.openhab.binding.squeezebox.internal.utils.SqueezeBoxTimeoutException;
34 import org.openhab.core.cache.ExpiringCacheMap;
35 import org.openhab.core.io.net.http.HttpUtil;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.IncreaseDecreaseType;
38 import org.openhab.core.library.types.NextPreviousType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.PercentType;
41 import org.openhab.core.library.types.PlayPauseType;
42 import org.openhab.core.library.types.RawType;
43 import org.openhab.core.library.types.RewindFastforwardType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.ThingStatusInfo;
50 import org.openhab.core.thing.ThingTypeUID;
51 import org.openhab.core.thing.binding.BaseThingHandler;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.RefreshType;
54 import org.openhab.core.types.State;
55 import org.openhab.core.types.StateOption;
56 import org.openhab.core.types.UnDefType;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
61 * The {@link SqueezeBoxPlayerHandler} is responsible for handling states, which
62 * are sent to/from channels.
64 * @author Dan Cunningham - Initial contribution
65 * @author Mark Hilbush - Improved handling of player status, prevent REFRESH from causing exception
66 * @author Mark Hilbush - Implement AudioSink and notifications
67 * @author Mark Hilbush - Added duration channel
68 * @author Patrik Gfeller - Timeout for TTS messages increased from 30 to 90s.
69 * @author Mark Hilbush - Get favorites from server and play favorite
70 * @author Mark Hilbush - Convert sound notification volume from channel to config parameter
71 * @author Mark Hilbush - Add like/unlike functionality
73 public class SqueezeBoxPlayerHandler extends BaseThingHandler implements SqueezeBoxPlayerEventListener {
74 private final Logger logger = LoggerFactory.getLogger(SqueezeBoxPlayerHandler.class);
76 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(SQUEEZEBOXPLAYER_THING_TYPE);
79 * We need to remember some states to change offsets in volume, time index,
82 protected Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
85 * Keeps current track time
87 private ScheduledFuture<?> timeCounterJob;
90 * Local reference to our bridge
92 private SqueezeBoxServerHandler squeezeBoxServerHandler;
95 * Our mac address, needed everywhere
100 * The server sends us the current time on play/pause/stop events, we
101 * increment it locally from there on
103 private int currentTime = 0;
106 * Our we playing something right now or not, need to keep current track
109 private boolean playing;
112 * Separate volume level for notifications
114 private Integer notificationSoundVolume = null;
116 private String callbackUrl;
118 private SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider;
120 private static final ExpiringCacheMap<String, RawType> IMAGE_CACHE = new ExpiringCacheMap<>(
121 TimeUnit.MINUTES.toMillis(15)); // 15min
123 private String likeCommand;
124 private String unlikeCommand;
125 private boolean connected = false;
128 * Creates SqueezeBox Player Handler
131 * @param stateDescriptionProvider
133 public SqueezeBoxPlayerHandler(@NonNull Thing thing, String callbackUrl,
134 SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider) {
136 this.callbackUrl = callbackUrl;
137 this.stateDescriptionProvider = stateDescriptionProvider;
141 public void initialize() {
142 mac = getConfig().as(SqueezeBoxPlayerConfig.class).mac;
145 logger.debug("player thing {} initialized with mac {}", getThing().getUID(), mac);
146 if (squeezeBoxServerHandler != null) {
147 // ensure we get an up-to-date connection state
148 squeezeBoxServerHandler.requestPlayers();
153 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
157 private void updateThingStatus() {
158 Thing bridge = getBridge();
159 if (bridge != null) {
160 squeezeBoxServerHandler = (SqueezeBoxServerHandler) bridge.getHandler();
161 ThingStatus bridgeStatus = bridge.getStatus();
163 if (bridgeStatus == ThingStatus.OFFLINE) {
164 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
165 } else if (!this.connected) {
166 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
167 } else if (bridgeStatus == ThingStatus.ONLINE && getThing().getStatus() != ThingStatus.ONLINE) {
168 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
171 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not found");
176 public void dispose() {
177 // stop our duration counter
178 if (timeCounterJob != null && !timeCounterJob.isCancelled()) {
179 timeCounterJob.cancel(true);
180 timeCounterJob = null;
183 if (squeezeBoxServerHandler != null) {
184 squeezeBoxServerHandler.removePlayerCache(mac);
186 logger.debug("player thing {} disposed for mac {}", getThing().getUID(), mac);
191 public void handleCommand(ChannelUID channelUID, Command command) {
192 if (squeezeBoxServerHandler == null) {
193 logger.debug("Player {} has no server configured, ignoring command: {}", getThing().getUID(), command);
196 // Some of the code below is not designed to handle REFRESH, only reply to channels where cached values exist
197 if (command == RefreshType.REFRESH) {
198 String channelID = channelUID.getId();
199 State newState = stateMap.get(channelID);
200 if (newState != null) {
201 updateState(channelID, newState);
206 switch (channelUID.getIdWithoutGroup()) {
208 if (command.equals(OnOffType.ON)) {
209 squeezeBoxServerHandler.powerOn(mac);
211 squeezeBoxServerHandler.powerOff(mac);
215 if (command.equals(OnOffType.ON)) {
216 squeezeBoxServerHandler.mute(mac);
218 squeezeBoxServerHandler.unMute(mac);
222 if (command.equals(OnOffType.ON)) {
223 squeezeBoxServerHandler.stop(mac);
224 } else if (command.equals(OnOffType.OFF)) {
225 squeezeBoxServerHandler.play(mac);
228 case CHANNEL_PLAY_PAUSE:
229 if (command.equals(OnOffType.ON)) {
230 squeezeBoxServerHandler.play(mac);
231 } else if (command.equals(OnOffType.OFF)) {
232 squeezeBoxServerHandler.pause(mac);
236 if (command.equals(OnOffType.ON)) {
237 squeezeBoxServerHandler.prev(mac);
241 if (command.equals(OnOffType.ON)) {
242 squeezeBoxServerHandler.next(mac);
246 if (command instanceof PercentType percentCommand) {
247 squeezeBoxServerHandler.setVolume(mac, percentCommand.intValue());
248 } else if (command.equals(IncreaseDecreaseType.INCREASE)) {
249 squeezeBoxServerHandler.volumeUp(mac, currentVolume());
250 } else if (command.equals(IncreaseDecreaseType.DECREASE)) {
251 squeezeBoxServerHandler.volumeDown(mac, currentVolume());
252 } else if (command.equals(OnOffType.OFF)) {
253 squeezeBoxServerHandler.mute(mac);
254 } else if (command.equals(OnOffType.ON)) {
255 squeezeBoxServerHandler.unMute(mac);
258 case CHANNEL_CONTROL:
259 if (command instanceof PlayPauseType) {
260 if (command.equals(PlayPauseType.PLAY)) {
261 squeezeBoxServerHandler.play(mac);
262 } else if (command.equals(PlayPauseType.PAUSE)) {
263 squeezeBoxServerHandler.pause(mac);
266 if (command instanceof NextPreviousType) {
267 if (command.equals(NextPreviousType.NEXT)) {
268 squeezeBoxServerHandler.next(mac);
269 } else if (command.equals(NextPreviousType.PREVIOUS)) {
270 squeezeBoxServerHandler.prev(mac);
273 if (command instanceof RewindFastforwardType) {
274 if (command.equals(RewindFastforwardType.REWIND)) {
275 squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() - 5);
276 } else if (command.equals(RewindFastforwardType.FASTFORWARD)) {
277 squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() + 5);
282 squeezeBoxServerHandler.playUrl(mac, command.toString());
285 if (command.toString().isBlank()) {
286 squeezeBoxServerHandler.unSyncPlayer(mac);
288 squeezeBoxServerHandler.syncPlayer(mac, command.toString());
292 if (command.equals(OnOffType.ON)) {
293 squeezeBoxServerHandler.unSyncPlayer(mac);
296 case CHANNEL_PLAYLIST_INDEX:
297 squeezeBoxServerHandler.playPlaylistItem(mac, ((DecimalType) command).intValue());
299 case CHANNEL_CURRENT_PLAYING_TIME:
300 squeezeBoxServerHandler.setPlayingTime(mac, ((DecimalType) command).intValue());
302 case CHANNEL_CURRENT_PLAYLIST_SHUFFLE:
303 squeezeBoxServerHandler.setShuffleMode(mac, ((DecimalType) command).intValue());
305 case CHANNEL_CURRENT_PLAYLIST_REPEAT:
306 squeezeBoxServerHandler.setRepeatMode(mac, ((DecimalType) command).intValue());
308 case CHANNEL_FAVORITES_PLAY:
309 squeezeBoxServerHandler.playFavorite(mac, command.toString());
312 if (command.equals(OnOffType.ON)) {
313 squeezeBoxServerHandler.rate(mac, likeCommand);
314 } else if (command.equals(OnOffType.OFF)) {
315 squeezeBoxServerHandler.rate(mac, unlikeCommand);
319 if (command instanceof DecimalType decimalCommand) {
320 Duration sleepDuration = Duration.ofMinutes(decimalCommand.longValue());
321 if (sleepDuration.isNegative() || sleepDuration.compareTo(Duration.ofDays(1)) > 0) {
322 logger.debug("Sleep timer of {} minutes must be >= 0 and <= 1 day", sleepDuration.toMinutes());
325 squeezeBoxServerHandler.sleep(mac, sleepDuration);
334 public void playerAdded(SqueezeBoxPlayer player) {
335 // Player properties are saved in SqueezeBoxPlayerDiscoveryParticipant
339 public void powerChangeEvent(String mac, boolean power) {
340 updateChannel(mac, CHANNEL_POWER, power ? OnOffType.ON : OnOffType.OFF);
341 if (!power && isMe(mac)) {
347 public synchronized void modeChangeEvent(String mac, String mode) {
348 updateChannel(mac, CHANNEL_CONTROL, "play".equals(mode) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
349 updateChannel(mac, CHANNEL_PLAY_PAUSE, "play".equals(mode) ? OnOffType.ON : OnOffType.OFF);
350 updateChannel(mac, CHANNEL_STOP, "stop".equals(mode) ? OnOffType.ON : OnOffType.OFF);
352 playing = "play".equalsIgnoreCase(mode);
357 public void sourceChangeEvent(String mac, String source) {
358 updateChannel(mac, CHANNEL_SOURCE, StringType.valueOf(source));
362 public void absoluteVolumeChangeEvent(String mac, int volume) {
363 int newVolume = volume;
364 newVolume = Math.min(100, newVolume);
365 newVolume = Math.max(0, newVolume);
366 updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
370 public void relativeVolumeChangeEvent(String mac, int volumeChange) {
371 int newVolume = currentVolume() + volumeChange;
372 newVolume = Math.min(100, newVolume);
373 newVolume = Math.max(0, newVolume);
374 updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
377 logger.trace("Volume changed [{}] for player {}. New volume: {}", volumeChange, mac, newVolume);
382 public void muteChangeEvent(String mac, boolean mute) {
383 updateChannel(mac, CHANNEL_MUTE, mute ? OnOffType.ON : OnOffType.OFF);
387 public void currentPlaylistIndexEvent(String mac, int index) {
388 updateChannel(mac, CHANNEL_PLAYLIST_INDEX, new DecimalType(index));
392 public void currentPlayingTimeEvent(String mac, int time) {
393 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new DecimalType(time));
400 public void durationEvent(String mac, int duration) {
401 if (getThing().getChannel(CHANNEL_DURATION) == null) {
402 logger.debug("Channel 'duration' does not exist. Delete and readd player thing to pick up channel.");
405 updateChannel(mac, CHANNEL_DURATION, new DecimalType(duration));
409 public void numberPlaylistTracksEvent(String mac, int track) {
410 updateChannel(mac, CHANNEL_NUMBER_PLAYLIST_TRACKS, new DecimalType(track));
414 public void currentPlaylistShuffleEvent(String mac, int shuffle) {
415 updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_SHUFFLE, new DecimalType(shuffle));
419 public void currentPlaylistRepeatEvent(String mac, int repeat) {
420 updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_REPEAT, new DecimalType(repeat));
424 public void titleChangeEvent(String mac, String title) {
425 updateChannel(mac, CHANNEL_TITLE, new StringType(title));
429 public void albumChangeEvent(String mac, String album) {
430 updateChannel(mac, CHANNEL_ALBUM, new StringType(album));
434 public void artistChangeEvent(String mac, String artist) {
435 updateChannel(mac, CHANNEL_ARTIST, new StringType(artist));
439 public void albumArtistChangeEvent(String mac, String albumArtist) {
440 updateChannel(mac, CHANNEL_ALBUM_ARTIST, new StringType(albumArtist));
444 public void trackArtistChangeEvent(String mac, String trackArtist) {
445 updateChannel(mac, CHANNEL_TRACK_ARTIST, new StringType(trackArtist));
449 public void bandChangeEvent(String mac, String band) {
450 updateChannel(mac, CHANNEL_BAND, new StringType(band));
454 public void composerChangeEvent(String mac, String composer) {
455 updateChannel(mac, CHANNEL_COMPOSER, new StringType(composer));
459 public void conductorChangeEvent(String mac, String conductor) {
460 updateChannel(mac, CHANNEL_CONDUCTOR, new StringType(conductor));
464 public void coverArtChangeEvent(String mac, String coverArtUrl) {
465 updateChannel(mac, CHANNEL_COVERART_DATA, createImage(downloadImage(mac, coverArtUrl)));
469 * Download and cache the image data from an URL.
471 * @param url The URL of the image to be downloaded.
472 * @return A RawType object containing the image, null if the content type could not be found or the content type is
475 private RawType downloadImage(String mac, String url) {
476 // Only get the image if this is my PlayerHandler instance
478 if (url != null && !url.isEmpty()) {
479 String sanitizedUrl = sanitizeUrl(url);
480 RawType image = IMAGE_CACHE.putIfAbsentAndGet(url, () -> {
481 logger.debug("Trying to download the content of URL {}", sanitizedUrl);
483 return HttpUtil.downloadImage(url);
484 } catch (IllegalArgumentException e) {
485 logger.debug("IllegalArgumentException when downloading image from {}", sanitizedUrl, e);
490 logger.debug("Failed to download the content of URL {}", sanitizedUrl);
501 * Replaces the password in the URL, if present
503 private String sanitizeUrl(String url) {
504 String sanitizedUrl = url;
506 URI uri = new URI(url);
507 String userInfo = uri.getUserInfo();
508 if (userInfo != null) {
509 String[] userInfoParts = userInfo.split(":");
510 if (userInfoParts.length == 2) {
511 sanitizedUrl = url.replace(userInfoParts[1], "**********");
514 } catch (URISyntaxException e) {
515 // Just return what was passed in
521 * Wrap the given RawType and return it as {@link State} or return {@link UnDefType#UNDEF} if the RawType is null.
523 private State createImage(RawType image) {
525 return UnDefType.UNDEF;
532 public void yearChangeEvent(String mac, String year) {
533 updateChannel(mac, CHANNEL_YEAR, new StringType(year));
537 public void genreChangeEvent(String mac, String genre) {
538 updateChannel(mac, CHANNEL_GENRE, new StringType(genre));
542 public void remoteTitleChangeEvent(String mac, String title) {
543 updateChannel(mac, CHANNEL_REMOTE_TITLE, new StringType(title));
547 public void irCodeChangeEvent(String mac, String ircode) {
549 postCommand(CHANNEL_IRCODE, new StringType(ircode));
554 public void buttonsChangeEvent(String mac, String likeCommand, String unlikeCommand) {
556 this.likeCommand = likeCommand;
557 this.unlikeCommand = unlikeCommand;
558 logger.trace("Player {} got a button change event: like='{}' unlike='{}'", mac, likeCommand, unlikeCommand);
563 public void connectedStateChangeEvent(String mac, boolean connected) {
565 this.connected = connected;
571 public void updateFavoritesListEvent(List<Favorite> favorites) {
572 logger.trace("Player {} updating favorites list with {} favorites", mac, favorites.size());
573 List<StateOption> options = new ArrayList<>();
574 for (Favorite favorite : favorites) {
575 options.add(new StateOption(favorite.shortId, favorite.name));
577 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_FAVORITES_PLAY), options);
581 * Update a channel if the mac matches our own
587 private void updateChannel(String mac, String channelID, State state) {
589 State prevState = stateMap.put(channelID, state);
590 if (prevState == null || !prevState.equals(state)) {
591 logger.trace("Updating channel {} for thing {} with mac {} to state {}", channelID, getThing().getUID(),
593 updateState(channelID, state);
599 * Helper methods to get the current state of the player
603 int currentVolume() {
604 return cachedStateAsInt(CHANNEL_VOLUME);
607 int currentPlayingTime() {
608 return cachedStateAsInt(CHANNEL_CURRENT_PLAYING_TIME);
611 int currentNumberPlaylistTracks() {
612 return cachedStateAsInt(CHANNEL_NUMBER_PLAYLIST_TRACKS);
615 int currentPlaylistIndex() {
616 return cachedStateAsInt(CHANNEL_PLAYLIST_INDEX);
619 boolean currentPower() {
620 return cachedStateAsBoolean(CHANNEL_POWER, OnOffType.ON);
623 boolean currentStop() {
624 return cachedStateAsBoolean(CHANNEL_STOP, OnOffType.ON);
627 boolean currentControl() {
628 return cachedStateAsBoolean(CHANNEL_CONTROL, PlayPauseType.PLAY);
631 boolean currentMute() {
632 return cachedStateAsBoolean(CHANNEL_MUTE, OnOffType.ON);
635 int currentShuffle() {
636 return cachedStateAsInt(CHANNEL_CURRENT_PLAYLIST_SHUFFLE);
639 int currentRepeat() {
640 return cachedStateAsInt(CHANNEL_CURRENT_PLAYLIST_REPEAT);
643 private boolean cachedStateAsBoolean(String key, @NonNull State activeState) {
644 return activeState.equals(stateMap.get(key));
647 private int cachedStateAsInt(String key) {
648 State state = stateMap.get(key);
649 return state instanceof DecimalType decimalValue ? decimalValue.intValue() : 0;
653 * Ticks away when in a play state to keep current track time
655 private void timeCounter() {
656 timeCounterJob = scheduler.scheduleWithFixedDelay(() -> {
658 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new DecimalType(currentTime++));
660 }, 0, 1, TimeUnit.SECONDS);
663 private boolean isMe(String mac) {
664 return mac.equals(this.mac);
668 * Returns our server handler if set
672 public SqueezeBoxServerHandler getSqueezeBoxServerHandler() {
673 return this.squeezeBoxServerHandler;
677 * Returns the MAC address for this player
681 public String getMac() {
686 * Give the notification player access to the notification timeout
688 public int getNotificationTimeout() {
689 return getConfigAs(SqueezeBoxPlayerConfig.class).notificationTimeout;
693 * Used by the AudioSink to get the volume level that should be used for the notification.
694 * Priority for determining volume is:
695 * - volume is provided in the say/playSound actions
696 * - volume is contained in the player thing's configuration
697 * - current player volume setting
699 public PercentType getNotificationSoundVolume() {
700 // Get the notification sound volume from this player thing's configuration
701 Integer configNotificationSoundVolume = getConfigAs(SqueezeBoxPlayerConfig.class).notificationVolume;
703 // Determine which volume to use
704 Integer currentNotificationSoundVolume;
705 if (notificationSoundVolume != null) {
706 currentNotificationSoundVolume = notificationSoundVolume;
707 } else if (configNotificationSoundVolume != null) {
708 currentNotificationSoundVolume = configNotificationSoundVolume;
710 currentNotificationSoundVolume = Integer.valueOf(currentVolume());
712 return new PercentType(currentNotificationSoundVolume.intValue());
716 * Used by the AudioSink to set the volume level that should be used to play the notification
718 public void setNotificationSoundVolume(PercentType newNotificationSoundVolume) {
719 if (newNotificationSoundVolume != null) {
720 notificationSoundVolume = Integer.valueOf(newNotificationSoundVolume.intValue());
725 * Play the notification.
727 public void playNotificationSoundURI(StringType uri) {
728 logger.debug("Play notification sound on player {} at URI {}", mac, uri);
730 try (SqueezeBoxNotificationPlayer notificationPlayer = new SqueezeBoxNotificationPlayer(this,
731 squeezeBoxServerHandler, uri)) {
732 notificationPlayer.play();
733 } catch (InterruptedException e) {
734 logger.warn("Notification playback was interrupted", e);
735 } catch (SqueezeBoxTimeoutException e) {
736 logger.debug("SqueezeBoxTimeoutException during notification: {}", e.getMessage());
738 notificationSoundVolume = null;
743 * Return the IP and port of the OH2 web server
745 public String getHostAndPort() {