/** * Copyright (c) 2010-2016 by the respective copyright holders. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ package org.openhab.io.squeezeserver; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.net.Socket; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Collections; import java.util.Dictionary; import java.util.Enumeration; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.NullArgumentException; import org.apache.commons.lang.StringUtils; import org.openhab.io.squeezeserver.SqueezePlayer.Mode; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Handles connection and event handling for all Squeezebox devices. * * @author Markus Wolters * @author Ben Jones * @since 1.4.0 */ public class SqueezeServer implements ManagedService { // TODO: should probably add some sort of watchdog timer to check the // 'listener' thread periodically so we can re-connect without having // to wait for a sendCommand() private static final Logger logger = LoggerFactory.getLogger(SqueezeServer.class); // configuration defaults for optional properties private static final int DEFAULT_CLI_PORT = 9090; private static final int DEFAULT_WEB_PORT = 9000; private static final int DEFAULT_RETRIES = 3; private static final int DEFAULT_RETRY_TIMEOUT = 60; private static final String DEFAULT_TTS_URL = "http://translate.google.com/translate_tts?tl=en&ie=UTF-8&client=openhab&q=%s"; private static final int DEFAULT_TTS_MAX_SENTENCE_LENGTH = 100; // / regEx to validate SqueezeServer config // <code>'^(squeeze:)(host|cliport|webport)=.+$'</code> private static final Pattern SERVER_CONFIG_PATTERN = Pattern.compile("^(server)\\.(host|cliport|webport|retries|retryTimeout)$"); // regEx to validate a mpdPlayer config <code>'^(.*?)\\.(id)$'</code> private static final Pattern PLAYER_CONFIG_PATTERN = Pattern.compile("^(.*?)\\.(id)$"); // regEx to select the tts url // <code>'^(ttsurl)$'</code> private static final Pattern TTS_URL_CONFIG_PATTERN = Pattern.compile("^(ttsurl)$"); // regEx to select the tts max sentence length // <code>'^(ttsmaxsentencelength)$'</code> private static final Pattern TTS_MAX_SENTENCE_LENGTH_CONFIG_PATTERN = Pattern.compile("^(ttsmaxsentencelength)$"); private static final String NEW_LINE = System.getProperty("line.separator"); // the value by which the volume is changed by each INCREASE or // DECREASE-Event private static final int VOLUME_CHANGE_SIZE = 5; // connection properties private String host; private int cliPort; private int webPort; private int retries; private int retryTimeout; // client socket and listener thread private Socket clientSocket; private SqueezeServerListener listener; // player listeners private final List<SqueezePlayerEventListener> playerEventListeners = Collections .synchronizedList(new ArrayList<SqueezePlayerEventListener>()); // configured players - keyed by playerId and MAC address private final Map<String, SqueezePlayer> playersById = new ConcurrentHashMap<String, SqueezePlayer>(); private final Map<String, SqueezePlayer> playersByMacAddress = new ConcurrentHashMap<String, SqueezePlayer>(); // tts properties private String ttsUrl; private int ttsMaxSentenceLength; private String _mutex = "wait"; public synchronized boolean isConnected() { if (clientSocket == null) { return false; } // NOTE: isConnected() returns true once a connection is made and will // always return true even after the socket is closed // http://stackoverflow.com/questions/10163358/ return clientSocket.isConnected() && !clientSocket.isClosed(); } public synchronized void addPlayerEventListener(SqueezePlayerEventListener playerEventListener) { if (!playerEventListeners.contains(playerEventListener)) { playerEventListeners.add(playerEventListener); } } public synchronized void removePlayerEventListener(SqueezePlayerEventListener playerEventListener) { playerEventListeners.remove(playerEventListener); } public synchronized List<SqueezePlayerEventListener> getPlayerEventListeners() { return new ArrayList<SqueezePlayerEventListener>(playerEventListeners); } public synchronized List<SqueezePlayer> getPlayers() { return new ArrayList<SqueezePlayer>(playersById.values()); } public synchronized SqueezePlayer getPlayer(String playerId) { if (StringUtils.isEmpty(playerId)) { throw new NullArgumentException("playerId"); } String key = playerId.toLowerCase(); if (!playersById.containsKey(key)) { logger.warn("No player exists for '{}'", playerId); return null; } return playersById.get(key); } public synchronized SqueezePlayer getPlayerByMacAddress(String macAddress) { String key = macAddress.toLowerCase(); if (!playersByMacAddress.containsKey(key)) { logger.warn("No player exists for MAC {}", macAddress); return null; } return playersByMacAddress.get(key); } public boolean mute(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } if (player.getVolume() == 0) { return true; } return setVolume(playerId, 0); } public boolean unMute(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } if (player.getVolume() > 0) { return true; } return setVolume(playerId, player.getUnmuteVolume()); } public boolean powerOn(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " power 1"); } public boolean powerOff(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " power 0"); } public boolean syncPlayer(String playerId1, String playerId2) { SqueezePlayer player1 = getPlayer(playerId1); SqueezePlayer player2 = getPlayer(playerId2); if (player1 == null || player2 == null) { return false; } return sendCommand(player1.getMacAddress() + " sync " + player2.getMacAddress()); } public boolean unSyncPlayer(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " sync -"); } public boolean play(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " play"); } public boolean playUrl(String playerId, String url) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist play " + url); } public boolean pause(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " pause 1"); } public boolean unPause(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " pause 0"); } public boolean stop(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " stop"); } public boolean prev(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist index -1"); } public boolean next(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist index +1"); } public boolean clearPlaylist(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist clear"); } public boolean deletePlaylistItem(String playerId, int playlistIndex) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist delete " + playlistIndex); } public boolean playPlaylistItem(String playerId, int playlistIndex) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist index " + playlistIndex); } public boolean addPlaylistItem(String playerId, String url) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist add " + url); } public boolean setPlayingTime(String playerId, int time) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " time " + time); } public boolean setRepeatMode(String playerId, int repeatMode) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist repeat " + repeatMode); } public boolean setShuffleMode(String playerId, int shuffleMode) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " playlist shuffle " + shuffleMode); } public boolean volumeUp(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return setVolume(playerId, player.getVolume() + VOLUME_CHANGE_SIZE); } public boolean volumeDown(String playerId) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return setVolume(playerId, player.getVolume() - VOLUME_CHANGE_SIZE); } public boolean setVolume(String playerId, int volume) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } if (0 > volume) { volume = 0; } else if (volume > 100) { volume = 100; } return sendCommand(player.getMacAddress() + " mixer volume " + String.valueOf(volume)); } public boolean showString(String playerId, String line) { return showString(playerId, line, 5); } public boolean showString(String playerId, String line, int duration) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " show line1:" + line + " duration:" + String.valueOf(duration)); } public boolean showStringHuge(String playerId, String line) { return showStringHuge(playerId, line, 5); } public boolean showStringHuge(String playerId, String line, int duration) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand( player.getMacAddress() + " show line1:" + line + " font:huge duration:" + String.valueOf(duration)); } public boolean showStrings(String playerId, String line1, String line2) { return showStrings(playerId, line1, line2, 5); } public boolean showStrings(String playerId, String line1, String line2, int duration) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " show line1:" + line1 + " line2:" + line2 + " duration:" + String.valueOf(duration)); } /** * Send a generic command to a given player * * @param playerId * @param command */ public boolean playerCommand(String playerId, String command) { SqueezePlayer player = getPlayer(playerId); if (player == null) { return false; } return sendCommand(player.getMacAddress() + " " + command); } public String getTtsUrl() { return ttsUrl; } public int getTtsMaxSentenceLength() { return ttsMaxSentenceLength; } /** * Send a command to the Squeeze Server. */ private synchronized boolean sendCommand(String command) { if (!isConnected()) { logger.debug("No connection to SqueezeServer, will attempt to reconnect now..."); connect(); if (!isConnected()) { logger.error("Failed to reconnect to SqueezeServer, unable to send command {}", command); return false; } } logger.debug("Sending command: {}", command); try { BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream())); writer.write(command + NEW_LINE); writer.flush(); return true; } catch (IOException e) { logger.error("Error while sending command to Squeeze Server (" + command + ")", e); return false; } } @Override public synchronized void updated(Dictionary<String, ?> config) throws ConfigurationException { // disconnect first in case the config has changed for an existing // instance disconnect(); host = null; cliPort = DEFAULT_CLI_PORT; webPort = DEFAULT_WEB_PORT; retries = DEFAULT_RETRIES; retryTimeout = DEFAULT_RETRY_TIMEOUT; ttsUrl = DEFAULT_TTS_URL; ttsMaxSentenceLength = DEFAULT_TTS_MAX_SENTENCE_LENGTH; playersById.clear(); playersByMacAddress.clear(); if (config == null || config.isEmpty()) { logger.warn("Empty or null configuration. Ignoring."); return; } Enumeration<String> keys = config.keys(); while (keys.hasMoreElements()) { String key = keys.nextElement(); // the config-key enumeration contains additional keys that we // don't want to process here ... if ("service.pid".equals(key)) { continue; } Matcher serverMatcher = SERVER_CONFIG_PATTERN.matcher(key); Matcher playerMatcher = PLAYER_CONFIG_PATTERN.matcher(key); Matcher ttsUrlMatcher = TTS_URL_CONFIG_PATTERN.matcher(key); Matcher ttsMaxSentenceLengthMatcher = TTS_MAX_SENTENCE_LENGTH_CONFIG_PATTERN.matcher(key); String value = (String) config.get(key); if (serverMatcher.matches()) { String serverConfig = serverMatcher.group(2); if (serverConfig.equals("host") && StringUtils.isNotBlank(value)) { host = value; } else if (serverConfig.equals("cliport") && StringUtils.isNotBlank(value)) { cliPort = Integer.valueOf(value); } else if (serverConfig.equals("webport") && StringUtils.isNotBlank(value)) { webPort = Integer.valueOf(value); } else if (serverConfig.equals("retries") && StringUtils.isNotBlank(value)) { retries = Integer.valueOf(value); } else if (serverConfig.equals("retryTimeout") && StringUtils.isNotBlank(value)) { retryTimeout = Integer.valueOf(value); } } else if (playerMatcher.matches()) { String playerId = playerMatcher.group(1); String macAddress = value; SqueezePlayer player = new SqueezePlayer(this, playerId, macAddress); playersById.put(playerId.toLowerCase(), player); playersByMacAddress.put(macAddress.toLowerCase(), player); } else if (ttsUrlMatcher.matches() && StringUtils.isNotBlank(value)) { ttsUrl = value; } else if (ttsMaxSentenceLengthMatcher.matches() && StringUtils.isNotBlank(value)) { ttsMaxSentenceLength = Integer.valueOf(value); } else { logger.warn("Ignored unexpected or unsupported configuration: {}", key); } } if (StringUtils.isEmpty(host)) { throw new ConfigurationException("host", "No Squeeze Server host specified - this property is mandatory"); } if (playersById.size() == 0) { throw new ConfigurationException("host", "No Squeezebox players specified - there must be at least one player"); } // attempt to connect using our new config if (!connect()) { retryConnect(); } } private boolean connect() { try { clientSocket = new Socket(host, cliPort); } catch (IOException e) { logger.error("Failed to connect to the Squeeze Server at " + host + ":" + cliPort, e); return false; } try { listener = new SqueezeServerListener(); listener.start(); } catch (IllegalThreadStateException e) { logger.error("Failed to start the Squeeze Server listener thread", e); return false; } logger.info("Squeeze Server connection started."); return true; } private boolean retryConnect() { int retriesRemaining = retries; boolean lastResult = false; while (retriesRemaining > 0) { synchronized (_mutex) { logger.info("Waiting {} seconds before retrying failed connection.", retryTimeout); try { _mutex.wait(retryTimeout*1000); }catch(InterruptedException e) { logger.warn("Timeout was interrupted."); } logger.info("Retrying failed connection (attempt {}/{}).", retries-retriesRemaining+1, retries); if (connect()) { return true; } retriesRemaining--; } } logger.error("All retries exhausted. Giving up on connecting to server."); return false; } private void disconnect() { if (!isConnected()) { return; } try { listener.terminate(); clientSocket.close(); } catch (IOException e) { logger.error("Error attempting to disconnect from Squeeze Server at " + host + ":" + cliPort, e); return; } finally { clientSocket = null; listener = null; } logger.info("Squeeze Server connection stopped."); } /** * Start service. */ public void activate() { logger.debug("Starting Squeeze Server connection..."); } /** * Stop service. */ public void deactivate() { logger.debug("Stopping Squeeze Server connection..."); disconnect(); } private class SqueezeServerListener extends Thread { private boolean terminate = false; public SqueezeServerListener() { super("Squeeze Server Listener"); } public void terminate() { logger.warn("Squeeze Server listener being terminated"); this.terminate = true; } @Override public void run() { logger.debug("Squeeze Server listener started."); BufferedReader reader = null; try { reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); sendCommand("players 0"); sendCommand("listen 1"); String message; while (!terminate && (message = reader.readLine()) != null) { logger.debug("Message received: {}", message); if (message.startsWith("listen 1")) { continue; } if (message.startsWith("players 0")) { handlePlayersList(message); } else { handlePlayerUpdate(message); } } } catch (IOException e) { logger.error("Error in Squeeze Server listener", e); } finally { if (reader != null) { try { reader.close(); } catch (IOException e) { // ignore } reader = null; } } logger.warn("Squeeze Server listener exiting."); } private String decode(String raw) { try { return URLDecoder.decode(raw, "UTF-8"); } catch (UnsupportedEncodingException e) { logger.error("Failed to decode '" + raw + "'", e); return null; } } private void handlePlayersList(String message) { String[] playersList = decode(message).split("playerindex:\\d+\\s"); for (String playerParams : playersList) { String[] parameterList = playerParams.split("\\s"); // parse out the MAC address first String macAddress = null; for (String parameter : parameterList) { if (parameter.contains("playerid")) { macAddress = parameter.substring(parameter.indexOf(":") + 1); break; } } // if none found then ignore this set of params if (macAddress == null) { continue; } // see if this player exists in our config SqueezePlayer player = getPlayerByMacAddress(macAddress); if (player == null) { continue; } // populate the player state for (String parameter : parameterList) { if (parameter.contains("ip")) { player.setIpAddr(parameter.substring(parameter.indexOf(":") + 1)); } else if (parameter.contains("uuid")) { player.setUuid(parameter.substring(parameter.indexOf(":") + 1)); } else if (parameter.contains("name")) { player.setName(parameter.substring(parameter.indexOf(":") + 1)); } else if (parameter.contains("model")) { player.setModel(parameter.substring(parameter.indexOf(":") + 1)); } } // tell the server we want to subscribe to player updates sendCommand(player.getMacAddress() + " status - 1 subscribe:10 tags:yagJlN"); } } private void handlePlayerUpdate(String message) { String[] messageParts = message.split("\\s"); if (messageParts.length < 2) { logger.warn("Invalid message - expecting at least 2 parts. Ignoring."); return; } // get the MAC address SqueezePlayer player = getPlayerByMacAddress(decode(messageParts[0])); if (player == null) { logger.warn("Status message received for MAC address {} which is not configured in openHAB. Ignoring.", messageParts[0]); return; } // get the message type String messageType = messageParts[1]; if (messageType.equals("status")) { handleStatusMessage(player, messageParts); } else if (messageType.equals("playlist")) { handlePlaylistMessage(player, messageParts); } else if (messageType.equals("prefset")) { handlePrefsetMessage(player, messageParts); } else if (messageType.equals("ir")) { player.setIrCode(messageParts[2]); } else if (messageType.equals("power")) { // ignore these for now // player.setPowered(messageParts[1].equals("1")); } else if (messageType.equals("play") || messageType.equals("pause") || messageType.equals("stop")) { // ignore these for now // player.setMode(Mode.valueOf(messageType)); } else if (messageType.equals("mixer") || messageType.equals("menustatus") || messageType.equals("button")) { // ignore these for now } else { logger.debug("Unhandled message type '{}'. Ignoring.", messageType); } } private void handleStatusMessage(SqueezePlayer player, String[] messageParts) { for (String messagePart : messageParts) { // Parameter Power if (messagePart.startsWith("power%3A")) { String value = messagePart.substring("power%3A".length()); player.setPowered(value.matches("1")); } // Parameter Volume else if (messagePart.startsWith("mixer%20volume%3A")) { String value = messagePart.substring("mixer%20volume%3A".length()); player.setVolume((int) Double.parseDouble(value)); } // Parameter Mode else if (messagePart.startsWith("mode%3A")) { String value = messagePart.substring("mode%3A".length()); player.setMode(Mode.valueOf(value)); } // Parameter Playing Time else if (messagePart.startsWith("time%3A")) { String value = messagePart.substring("time%3A".length()); player.setCurrentPlayingTime((int) Double.parseDouble(value)); } // Parameter Playing Playlist Index else if (messagePart.startsWith("playlist_cur_index%3A")) { String value = messagePart.substring("playlist_cur_index%3A".length()); player.setCurrentPlaylistIndex(Integer.parseInt(value)); } // Parameter Playlist Number Tracks else if (messagePart.startsWith("playlist_tracks%3A")) { String value = messagePart.substring("playlist_tracks%3A".length()); player.setNumberPlaylistTracks(Integer.parseInt(value)); } // Parameter Playlist Repeat Mode else if (messagePart.startsWith("playlist%20repeat%3A")) { String value = messagePart.substring("playlist%20repeat%3A".length()); player.setCurrentPlaylistRepeat(Integer.parseInt(value)); } // Parameter Playlist Shuffle Mode else if (messagePart.startsWith("playlist%20shuffle%3A")) { String value = messagePart.substring("playlist%20shuffle%3A".length()); player.setCurrentPlaylistShuffle(Integer.parseInt(value)); } // Parameter Playlist Number Tracks else if (messagePart.startsWith("playlist_tracks%3A")) { String value = messagePart.substring("playlist_tracks%3A".length()); player.setNumberPlaylistTracks(Integer.parseInt(value)); } // Parameter Title else if (messagePart.startsWith("title%3A")) { String value = messagePart.substring("title%3A".length()); player.setTitle(decode(value)); } // Parameter Remote Title (radio) else if (messagePart.startsWith("remote_title%3A")) { String value = messagePart.substring("remote_title%3A".length()); player.setRemoteTitle(decode(value)); } // Parameter Artist else if (messagePart.startsWith("artist%3A")) { String value = messagePart.substring("artist%3A".length()); player.setArtist(decode(value)); } // Parameter Album else if (messagePart.startsWith("album%3A")) { String value = messagePart.substring("album%3A".length()); player.setAlbum(decode(value)); } // Parameter Genre else if (messagePart.startsWith("genre%3A")) { String value = messagePart.substring("genre%3A".length()); player.setGenre(decode(value)); } // Parameter Year else if (messagePart.startsWith("year%3A")) { String value = messagePart.substring("year%3A".length()); player.setYear(Integer.parseInt(value)); } // Parameter Artwork else if (messagePart.startsWith("artwork_track_id%3A")) { String value = messagePart.substring("artwork_track_id%3A".length()); // NOTE: what is returned if not an artwork id? i.e. if a // space? if (!value.startsWith(" ")) { value = "http://" + host + ":" + webPort + "/music/" + value + "/cover.jpg"; } player.setCoverArt(decode(value)); } } } private void handlePlaylistMessage(SqueezePlayer player, String[] messageParts) { String action = messageParts[2]; if (action.equals("newsong")) { player.setMode(Mode.play); } else if (action.equals("pause")) { player.setMode(messageParts[3].equals("0") ? Mode.play : Mode.pause); } else if (action.equals("stop")) { player.setMode(Mode.stop); } } private void handlePrefsetMessage(SqueezePlayer player, String[] messageParts) { if (messageParts.length < 5) { return; } // server prefsets if (messageParts[2].equals("server")) { String function = messageParts[3]; String value = messageParts[4]; if (function.equals("power")) { player.setPowered(value.equals("1")); } else if (function.equals("volume")) { player.setVolume(Integer.parseInt(value)); } } } } }