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.QuantityType;
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.library.unit.Units;
47 import org.openhab.core.thing.ChannelUID;
48 import org.openhab.core.thing.Thing;
49 import org.openhab.core.thing.ThingStatus;
50 import org.openhab.core.thing.ThingStatusDetail;
51 import org.openhab.core.thing.ThingStatusInfo;
52 import org.openhab.core.thing.ThingTypeUID;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.types.Command;
55 import org.openhab.core.types.RefreshType;
56 import org.openhab.core.types.State;
57 import org.openhab.core.types.StateOption;
58 import org.openhab.core.types.UnDefType;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
63 * The {@link SqueezeBoxPlayerHandler} is responsible for handling states, which
64 * are sent to/from channels.
66 * @author Dan Cunningham - Initial contribution
67 * @author Mark Hilbush - Improved handling of player status, prevent REFRESH from causing exception
68 * @author Mark Hilbush - Implement AudioSink and notifications
69 * @author Mark Hilbush - Added duration channel
70 * @author Patrik Gfeller - Timeout for TTS messages increased from 30 to 90s.
71 * @author Mark Hilbush - Get favorites from server and play favorite
72 * @author Mark Hilbush - Convert sound notification volume from channel to config parameter
73 * @author Mark Hilbush - Add like/unlike functionality
75 public class SqueezeBoxPlayerHandler extends BaseThingHandler implements SqueezeBoxPlayerEventListener {
76 private final Logger logger = LoggerFactory.getLogger(SqueezeBoxPlayerHandler.class);
78 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(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;
127 private boolean connected = false;
130 * Creates SqueezeBox Player Handler
133 * @param stateDescriptionProvider
135 public SqueezeBoxPlayerHandler(@NonNull Thing thing, String callbackUrl,
136 SqueezeBoxStateDescriptionOptionsProvider stateDescriptionProvider) {
138 this.callbackUrl = callbackUrl;
139 this.stateDescriptionProvider = stateDescriptionProvider;
143 public void initialize() {
144 mac = getConfig().as(SqueezeBoxPlayerConfig.class).mac;
147 logger.debug("player thing {} initialized with mac {}", getThing().getUID(), mac);
148 if (squeezeBoxServerHandler != null) {
149 // ensure we get an up-to-date connection state
150 squeezeBoxServerHandler.requestPlayers();
155 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
159 private void updateThingStatus() {
160 Thing bridge = getBridge();
161 if (bridge != null) {
162 squeezeBoxServerHandler = (SqueezeBoxServerHandler) bridge.getHandler();
163 ThingStatus bridgeStatus = bridge.getStatus();
165 if (bridgeStatus == ThingStatus.OFFLINE) {
166 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
167 } else if (!this.connected) {
168 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE);
169 } else if (bridgeStatus == ThingStatus.ONLINE && getThing().getStatus() != ThingStatus.ONLINE) {
170 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
173 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not found");
178 public void dispose() {
179 // stop our duration counter
180 if (timeCounterJob != null && !timeCounterJob.isCancelled()) {
181 timeCounterJob.cancel(true);
182 timeCounterJob = null;
185 if (squeezeBoxServerHandler != null) {
186 squeezeBoxServerHandler.removePlayerCache(mac);
188 logger.debug("player thing {} disposed for mac {}", getThing().getUID(), mac);
193 public void handleCommand(ChannelUID channelUID, Command command) {
194 if (squeezeBoxServerHandler == null) {
195 logger.debug("Player {} has no server configured, ignoring command: {}", getThing().getUID(), command);
198 // Some of the code below is not designed to handle REFRESH, only reply to channels where cached values exist
199 if (command == RefreshType.REFRESH) {
200 String channelID = channelUID.getId();
201 State newState = stateMap.get(channelID);
202 if (newState != null) {
203 updateState(channelID, newState);
208 switch (channelUID.getIdWithoutGroup()) {
210 if (command.equals(OnOffType.ON)) {
211 squeezeBoxServerHandler.powerOn(mac);
213 squeezeBoxServerHandler.powerOff(mac);
217 if (command.equals(OnOffType.ON)) {
218 squeezeBoxServerHandler.mute(mac);
220 squeezeBoxServerHandler.unMute(mac);
224 if (command.equals(OnOffType.ON)) {
225 squeezeBoxServerHandler.stop(mac);
226 } else if (command.equals(OnOffType.OFF)) {
227 squeezeBoxServerHandler.play(mac);
230 case CHANNEL_PLAY_PAUSE:
231 if (command.equals(OnOffType.ON)) {
232 squeezeBoxServerHandler.play(mac);
233 } else if (command.equals(OnOffType.OFF)) {
234 squeezeBoxServerHandler.pause(mac);
238 if (command.equals(OnOffType.ON)) {
239 squeezeBoxServerHandler.prev(mac);
243 if (command.equals(OnOffType.ON)) {
244 squeezeBoxServerHandler.next(mac);
248 if (command instanceof PercentType percentCommand) {
249 squeezeBoxServerHandler.setVolume(mac, percentCommand.intValue());
250 } else if (command.equals(IncreaseDecreaseType.INCREASE)) {
251 squeezeBoxServerHandler.volumeUp(mac, currentVolume());
252 } else if (command.equals(IncreaseDecreaseType.DECREASE)) {
253 squeezeBoxServerHandler.volumeDown(mac, currentVolume());
254 } else if (command.equals(OnOffType.OFF)) {
255 squeezeBoxServerHandler.mute(mac);
256 } else if (command.equals(OnOffType.ON)) {
257 squeezeBoxServerHandler.unMute(mac);
260 case CHANNEL_CONTROL:
261 if (command instanceof PlayPauseType) {
262 if (command.equals(PlayPauseType.PLAY)) {
263 squeezeBoxServerHandler.play(mac);
264 } else if (command.equals(PlayPauseType.PAUSE)) {
265 squeezeBoxServerHandler.pause(mac);
268 if (command instanceof NextPreviousType) {
269 if (command.equals(NextPreviousType.NEXT)) {
270 squeezeBoxServerHandler.next(mac);
271 } else if (command.equals(NextPreviousType.PREVIOUS)) {
272 squeezeBoxServerHandler.prev(mac);
275 if (command instanceof RewindFastforwardType) {
276 if (command.equals(RewindFastforwardType.REWIND)) {
277 squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() - 5);
278 } else if (command.equals(RewindFastforwardType.FASTFORWARD)) {
279 squeezeBoxServerHandler.setPlayingTime(mac, currentPlayingTime() + 5);
284 squeezeBoxServerHandler.playUrl(mac, command.toString());
287 if (command.toString().isBlank()) {
288 squeezeBoxServerHandler.unSyncPlayer(mac);
290 squeezeBoxServerHandler.syncPlayer(mac, command.toString());
294 if (command.equals(OnOffType.ON)) {
295 squeezeBoxServerHandler.unSyncPlayer(mac);
298 case CHANNEL_PLAYLIST_INDEX:
299 squeezeBoxServerHandler.playPlaylistItem(mac, ((DecimalType) command).intValue());
301 case CHANNEL_CURRENT_PLAYING_TIME:
302 if (command instanceof DecimalType decimalCommand) {
303 squeezeBoxServerHandler.setPlayingTime(mac, decimalCommand.intValue());
304 } else if (command instanceof QuantityType<?> quantityCommand) {
305 QuantityType<?> quantitySeconds = quantityCommand.toUnit(Units.SECOND);
306 if (quantitySeconds != null) {
307 squeezeBoxServerHandler.setPlayingTime(mac, quantitySeconds.intValue());
311 case CHANNEL_CURRENT_PLAYLIST_SHUFFLE:
312 squeezeBoxServerHandler.setShuffleMode(mac, ((DecimalType) command).intValue());
314 case CHANNEL_CURRENT_PLAYLIST_REPEAT:
315 squeezeBoxServerHandler.setRepeatMode(mac, ((DecimalType) command).intValue());
317 case CHANNEL_FAVORITES_PLAY:
318 squeezeBoxServerHandler.playFavorite(mac, command.toString());
321 if (command.equals(OnOffType.ON)) {
322 squeezeBoxServerHandler.rate(mac, likeCommand);
323 } else if (command.equals(OnOffType.OFF)) {
324 squeezeBoxServerHandler.rate(mac, unlikeCommand);
328 if (command instanceof DecimalType decimalCommand) {
329 Duration sleepDuration = Duration.ofMinutes(decimalCommand.longValue());
330 if (sleepDuration.isNegative() || sleepDuration.compareTo(Duration.ofDays(1)) > 0) {
331 logger.debug("Sleep timer of {} minutes must be >= 0 and <= 1 day", sleepDuration.toMinutes());
334 squeezeBoxServerHandler.sleep(mac, sleepDuration);
343 public void playerAdded(SqueezeBoxPlayer player) {
344 // Player properties are saved in SqueezeBoxPlayerDiscoveryParticipant
348 public void powerChangeEvent(String mac, boolean power) {
349 updateChannel(mac, CHANNEL_POWER, power ? OnOffType.ON : OnOffType.OFF);
350 if (!power && isMe(mac)) {
356 public synchronized void modeChangeEvent(String mac, String mode) {
357 updateChannel(mac, CHANNEL_CONTROL, "play".equals(mode) ? PlayPauseType.PLAY : PlayPauseType.PAUSE);
358 updateChannel(mac, CHANNEL_PLAY_PAUSE, "play".equals(mode) ? OnOffType.ON : OnOffType.OFF);
359 updateChannel(mac, CHANNEL_STOP, "stop".equals(mode) ? OnOffType.ON : OnOffType.OFF);
361 playing = "play".equalsIgnoreCase(mode);
366 public void sourceChangeEvent(String mac, String source) {
367 updateChannel(mac, CHANNEL_SOURCE, StringType.valueOf(source));
371 public void absoluteVolumeChangeEvent(String mac, int volume) {
372 int newVolume = volume;
373 newVolume = Math.min(100, newVolume);
374 newVolume = Math.max(0, newVolume);
375 updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
379 public void relativeVolumeChangeEvent(String mac, int volumeChange) {
380 int newVolume = currentVolume() + volumeChange;
381 newVolume = Math.min(100, newVolume);
382 newVolume = Math.max(0, newVolume);
383 updateChannel(mac, CHANNEL_VOLUME, new PercentType(newVolume));
386 logger.trace("Volume changed [{}] for player {}. New volume: {}", volumeChange, mac, newVolume);
391 public void muteChangeEvent(String mac, boolean mute) {
392 updateChannel(mac, CHANNEL_MUTE, mute ? OnOffType.ON : OnOffType.OFF);
396 public void currentPlaylistIndexEvent(String mac, int index) {
397 updateChannel(mac, CHANNEL_PLAYLIST_INDEX, new DecimalType(index));
401 public void currentPlayingTimeEvent(String mac, int time) {
402 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new QuantityType<>(time, Units.SECOND));
409 public void durationEvent(String mac, int duration) {
410 if (getThing().getChannel(CHANNEL_DURATION) == null) {
411 logger.debug("Channel 'duration' does not exist. Delete and readd player thing to pick up channel.");
414 updateChannel(mac, CHANNEL_DURATION, new QuantityType<>(duration, Units.SECOND));
418 public void numberPlaylistTracksEvent(String mac, int track) {
419 updateChannel(mac, CHANNEL_NUMBER_PLAYLIST_TRACKS, new DecimalType(track));
423 public void currentPlaylistShuffleEvent(String mac, int shuffle) {
424 updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_SHUFFLE, new DecimalType(shuffle));
428 public void currentPlaylistRepeatEvent(String mac, int repeat) {
429 updateChannel(mac, CHANNEL_CURRENT_PLAYLIST_REPEAT, new DecimalType(repeat));
433 public void titleChangeEvent(String mac, String title) {
434 updateChannel(mac, CHANNEL_TITLE, new StringType(title));
438 public void albumChangeEvent(String mac, String album) {
439 updateChannel(mac, CHANNEL_ALBUM, new StringType(album));
443 public void artistChangeEvent(String mac, String artist) {
444 updateChannel(mac, CHANNEL_ARTIST, new StringType(artist));
448 public void albumArtistChangeEvent(String mac, String albumArtist) {
449 updateChannel(mac, CHANNEL_ALBUM_ARTIST, new StringType(albumArtist));
453 public void trackArtistChangeEvent(String mac, String trackArtist) {
454 updateChannel(mac, CHANNEL_TRACK_ARTIST, new StringType(trackArtist));
458 public void bandChangeEvent(String mac, String band) {
459 updateChannel(mac, CHANNEL_BAND, new StringType(band));
463 public void composerChangeEvent(String mac, String composer) {
464 updateChannel(mac, CHANNEL_COMPOSER, new StringType(composer));
468 public void conductorChangeEvent(String mac, String conductor) {
469 updateChannel(mac, CHANNEL_CONDUCTOR, new StringType(conductor));
473 public void coverArtChangeEvent(String mac, String coverArtUrl) {
474 updateChannel(mac, CHANNEL_COVERART_DATA, createImage(downloadImage(mac, coverArtUrl)));
478 * Download and cache the image data from an URL.
480 * @param url The URL of the image to be downloaded.
481 * @return A RawType object containing the image, null if the content type could not be found or the content type is
484 private RawType downloadImage(String mac, String url) {
485 // Only get the image if this is my PlayerHandler instance
487 if (url != null && !url.isEmpty()) {
488 String sanitizedUrl = sanitizeUrl(url);
489 RawType image = IMAGE_CACHE.putIfAbsentAndGet(url, () -> {
490 logger.debug("Trying to download the content of URL {}", sanitizedUrl);
492 return HttpUtil.downloadImage(url);
493 } catch (IllegalArgumentException e) {
494 logger.debug("IllegalArgumentException when downloading image from {}", sanitizedUrl, e);
499 logger.debug("Failed to download the content of URL {}", sanitizedUrl);
510 * Replaces the password in the URL, if present
512 private String sanitizeUrl(String url) {
513 String sanitizedUrl = url;
515 URI uri = new URI(url);
516 String userInfo = uri.getUserInfo();
517 if (userInfo != null) {
518 String[] userInfoParts = userInfo.split(":");
519 if (userInfoParts.length == 2) {
520 sanitizedUrl = url.replace(userInfoParts[1], "**********");
523 } catch (URISyntaxException e) {
524 // Just return what was passed in
530 * Wrap the given RawType and return it as {@link State} or return {@link UnDefType#UNDEF} if the RawType is null.
532 private State createImage(RawType image) {
534 return UnDefType.UNDEF;
541 public void yearChangeEvent(String mac, String year) {
542 updateChannel(mac, CHANNEL_YEAR, new StringType(year));
546 public void genreChangeEvent(String mac, String genre) {
547 updateChannel(mac, CHANNEL_GENRE, new StringType(genre));
551 public void remoteTitleChangeEvent(String mac, String title) {
552 updateChannel(mac, CHANNEL_REMOTE_TITLE, new StringType(title));
556 public void irCodeChangeEvent(String mac, String ircode) {
558 postCommand(CHANNEL_IRCODE, new StringType(ircode));
563 public void buttonsChangeEvent(String mac, String likeCommand, String unlikeCommand) {
565 this.likeCommand = likeCommand;
566 this.unlikeCommand = unlikeCommand;
567 logger.trace("Player {} got a button change event: like='{}' unlike='{}'", mac, likeCommand, unlikeCommand);
572 public void connectedStateChangeEvent(String mac, boolean connected) {
574 this.connected = connected;
580 public void updateFavoritesListEvent(List<Favorite> favorites) {
581 logger.trace("Player {} updating favorites list with {} favorites", mac, favorites.size());
582 List<StateOption> options = new ArrayList<>();
583 for (Favorite favorite : favorites) {
584 options.add(new StateOption(favorite.shortId, favorite.name));
586 stateDescriptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_FAVORITES_PLAY), options);
590 * Update a channel if the mac matches our own
596 private void updateChannel(String mac, String channelID, State state) {
598 State prevState = stateMap.put(channelID, state);
599 if (prevState == null || !prevState.equals(state)) {
600 logger.trace("Updating channel {} for thing {} with mac {} to state {}", channelID, getThing().getUID(),
602 updateState(channelID, state);
608 * Helper methods to get the current state of the player
612 int currentVolume() {
613 return cachedStateAsInt(CHANNEL_VOLUME);
616 int currentPlayingTime() {
617 return cachedStateAsInt(CHANNEL_CURRENT_PLAYING_TIME);
620 int currentNumberPlaylistTracks() {
621 return cachedStateAsInt(CHANNEL_NUMBER_PLAYLIST_TRACKS);
624 int currentPlaylistIndex() {
625 return cachedStateAsInt(CHANNEL_PLAYLIST_INDEX);
628 boolean currentPower() {
629 return cachedStateAsBoolean(CHANNEL_POWER, OnOffType.ON);
632 boolean currentStop() {
633 return cachedStateAsBoolean(CHANNEL_STOP, OnOffType.ON);
636 boolean currentControl() {
637 return cachedStateAsBoolean(CHANNEL_CONTROL, PlayPauseType.PLAY);
640 boolean currentMute() {
641 return cachedStateAsBoolean(CHANNEL_MUTE, OnOffType.ON);
644 int currentShuffle() {
645 return cachedStateAsInt(CHANNEL_CURRENT_PLAYLIST_SHUFFLE);
648 int currentRepeat() {
649 return cachedStateAsInt(CHANNEL_CURRENT_PLAYLIST_REPEAT);
652 private boolean cachedStateAsBoolean(String key, @NonNull State activeState) {
653 return activeState.equals(stateMap.get(key));
656 private int cachedStateAsInt(String key) {
657 State state = stateMap.get(key);
658 if (state instanceof DecimalType decimalValue) {
659 return decimalValue.intValue();
660 } else if (state instanceof QuantityType<?> quantityValue) {
661 QuantityType<?> quantitySeconds = quantityValue.toUnit(Units.SECOND);
662 if (quantitySeconds != null) {
663 return quantitySeconds.intValue();
670 * Ticks away when in a play state to keep current track time
672 private void timeCounter() {
673 timeCounterJob = scheduler.scheduleWithFixedDelay(() -> {
675 updateChannel(mac, CHANNEL_CURRENT_PLAYING_TIME, new QuantityType<>(currentTime++, Units.SECOND));
677 }, 0, 1, TimeUnit.SECONDS);
680 private boolean isMe(String mac) {
681 return mac.equals(this.mac);
685 * Returns our server handler if set
689 public SqueezeBoxServerHandler getSqueezeBoxServerHandler() {
690 return this.squeezeBoxServerHandler;
694 * Returns the MAC address for this player
698 public String getMac() {
703 * Give the notification player access to the notification timeout
705 public int getNotificationTimeout() {
706 return getConfigAs(SqueezeBoxPlayerConfig.class).notificationTimeout;
710 * Used by the AudioSink to get the volume level that should be used for the notification.
711 * Priority for determining volume is:
712 * - volume is provided in the say/playSound actions
713 * - volume is contained in the player thing's configuration
714 * - current player volume setting
716 public PercentType getNotificationSoundVolume() {
717 // Get the notification sound volume from this player thing's configuration
718 Integer configNotificationSoundVolume = getConfigAs(SqueezeBoxPlayerConfig.class).notificationVolume;
720 // Determine which volume to use
721 Integer currentNotificationSoundVolume;
722 if (notificationSoundVolume != null) {
723 currentNotificationSoundVolume = notificationSoundVolume;
724 } else if (configNotificationSoundVolume != null) {
725 currentNotificationSoundVolume = configNotificationSoundVolume;
727 currentNotificationSoundVolume = Integer.valueOf(currentVolume());
729 return new PercentType(currentNotificationSoundVolume.intValue());
733 * Used by the AudioSink to set the volume level that should be used to play the notification
735 public void setNotificationSoundVolume(PercentType newNotificationSoundVolume) {
736 if (newNotificationSoundVolume != null) {
737 notificationSoundVolume = Integer.valueOf(newNotificationSoundVolume.intValue());
742 * Play the notification.
744 public void playNotificationSoundURI(StringType uri) {
745 logger.debug("Play notification sound on player {} at URI {}", mac, uri);
747 try (SqueezeBoxNotificationPlayer notificationPlayer = new SqueezeBoxNotificationPlayer(this,
748 squeezeBoxServerHandler, uri)) {
749 notificationPlayer.play();
750 } catch (InterruptedException e) {
751 logger.warn("Notification playback was interrupted", e);
752 } catch (SqueezeBoxTimeoutException e) {
753 logger.debug("SqueezeBoxTimeoutException during notification: {}", e.getMessage());
755 notificationSoundVolume = null;
760 * Return the IP and port of the OH2 web server
762 public String getHostAndPort() {