/** * 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.binding.xbmc.rpc; import java.io.IOException; import java.math.BigDecimal; import java.net.ConnectException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import org.apache.commons.lang.StringUtils; import org.codehaus.jackson.JsonParseException; import org.codehaus.jackson.map.JsonMappingException; import org.codehaus.jackson.map.ObjectMapper; import org.openhab.binding.xbmc.internal.XbmcHost; import org.openhab.binding.xbmc.rpc.calls.ApplicationGetProperties; import org.openhab.binding.xbmc.rpc.calls.ApplicationSetVolume; import org.openhab.binding.xbmc.rpc.calls.FilesPrepareDownload; import org.openhab.binding.xbmc.rpc.calls.GUIShowNotification; import org.openhab.binding.xbmc.rpc.calls.InputExecuteAction; import org.openhab.binding.xbmc.rpc.calls.JSONRPCPing; import org.openhab.binding.xbmc.rpc.calls.PVRGetChannels; import org.openhab.binding.xbmc.rpc.calls.PlayerGetActivePlayers; import org.openhab.binding.xbmc.rpc.calls.PlayerGetItem; import org.openhab.binding.xbmc.rpc.calls.PlayerGetLabels; import org.openhab.binding.xbmc.rpc.calls.PlayerGetProperties; import org.openhab.binding.xbmc.rpc.calls.PlayerOpen; import org.openhab.binding.xbmc.rpc.calls.PlayerPlayPause; import org.openhab.binding.xbmc.rpc.calls.PlayerStop; import org.openhab.binding.xbmc.rpc.calls.SystemHibernate; import org.openhab.binding.xbmc.rpc.calls.SystemReboot; import org.openhab.binding.xbmc.rpc.calls.SystemShutdown; import org.openhab.binding.xbmc.rpc.calls.SystemSuspend; import org.openhab.binding.xbmc.rpc.calls.XBMCGetInfoBooleans; import org.openhab.core.events.EventPublisher; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.StringType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.ning.http.client.AsyncHttpClient; import com.ning.http.client.AsyncHttpClientConfig; import com.ning.http.client.AsyncHttpClientConfig.Builder; import com.ning.http.client.Realm; import com.ning.http.client.Realm.AuthScheme; import com.ning.http.client.providers.netty.NettyAsyncHttpProvider; import com.ning.http.client.websocket.WebSocket; import com.ning.http.client.websocket.WebSocketTextListener; import com.ning.http.client.websocket.WebSocketUpgradeHandler; /** * Manages the web socket connection for a single XBMC instance. * * @author tlan, Ben Jones, Ard van der Leeuw, Plebs * @since 1.5.0 */ public class XbmcConnector { private static final String SCREENSAVER_STATE = "Screensaver.State"; private static final Logger logger = LoggerFactory.getLogger(XbmcConnector.class); // request timeout (configurable?) private static final int REQUEST_TIMEOUT_MS = 60000; // the amount to increase/decrease the volume when receiving INCREASE/DECREASE commands private static final BigDecimal VOLUMESTEP = new BigDecimal(10); // the XBMC instance and openHAB event publisher handles private final XbmcHost xbmc; private final EventPublisher eventPublisher; private final String httpUri; private final String wsUri; private final AsyncHttpClient client; private final WebSocketUpgradeHandler handler; // stores which property is associated with each item private final Map<String, String> watches = new HashMap<String, String>(); // the async connection to the XBMC instance private WebSocket webSocket; private boolean connected = false; // the current volume private BigDecimal volume = BigDecimal.ZERO; // the current player state private State currentState = State.Stop; private enum State { Play, Pause, End, Stop } /** * @param xbmc * The host to connect to. Give a reachable hostname or ip * address, without protocol or port * @param eventPublisher * EventPublisher to push out state updates */ public XbmcConnector(XbmcHost xbmc, EventPublisher eventPublisher) { this.xbmc = xbmc; this.eventPublisher = eventPublisher; this.httpUri = String.format("http://%s:%d/jsonrpc", xbmc.getHostname(), xbmc.getPort()); this.wsUri = String.format("ws://%s:%d/jsonrpc", xbmc.getHostname(), xbmc.getWSPort()); this.client = new AsyncHttpClient(new NettyAsyncHttpProvider(createAsyncHttpClientConfig())); this.handler = createWebSocketHandler(); } /*** * Check if the connection to the XBMC instance is active * * @return true if an active connection to the XBMC instance exists, false otherwise */ public boolean isConnected() { if (webSocket == null || !webSocket.isOpen()) { return false; } return connected; } /** * Attempts to create a connection to the XBMC host and begin listening * for updates over the async http web socket * * @throws ExecutionException * @throws InterruptedException * @throws IOException */ public void open() throws IOException, InterruptedException, ExecutionException { // cleanup any existing web socket left over from previous attempts close(); // attempt to open the web socket connection to the XBMC instance webSocket = client.prepareGet(wsUri).execute(handler).get(); } /*** * Close this connection to the XBMC instance */ public void close() { // if there is an old web socket then clean up and destroy if (webSocket != null) { webSocket.close(); webSocket = null; } } private AsyncHttpClientConfig createAsyncHttpClientConfig() { Builder builder = new AsyncHttpClientConfig.Builder(); builder.setRealm(createRealm()); builder.setRequestTimeoutInMs(REQUEST_TIMEOUT_MS); return builder.build(); } private Realm createRealm() { Realm.RealmBuilder builder = new Realm.RealmBuilder(); builder.setPrincipal(xbmc.getUsername()); builder.setPassword(xbmc.getPassword()); builder.setUsePreemptiveAuth(true); builder.setScheme(AuthScheme.BASIC); return builder.build(); } private WebSocketUpgradeHandler createWebSocketHandler() { WebSocketUpgradeHandler.Builder builder = new WebSocketUpgradeHandler.Builder(); builder.addWebSocketListener(new XbmcWebSocketListener()); return builder.build(); } class XbmcWebSocketListener implements WebSocketTextListener { private final ObjectMapper mapper = new ObjectMapper(); @Override public void onOpen(WebSocket webSocket) { logger.debug("[{}]: Websocket opened", xbmc.getHostname()); connected = true; requestApplicationUpdate(); requestScreenSaverStateUpdate(); updatePlayerStatus(); updateProperty("System.State", OnOffType.ON); } @Override public void onError(Throwable e) { if (e instanceof ConnectException) { logger.debug("[{}]: Websocket connection error '{}'", xbmc.getHostname(), e.getMessage()); } else if (e instanceof TimeoutException) { logger.debug("[{}]: Websocket timeout error", xbmc.getHostname()); } else { logger.error("[{}]: Websocket error: {}", xbmc.getHostname(), e.getMessage()); } } @Override public void onClose(WebSocket webSocket) { logger.warn("[{}]: Websocket closed", xbmc.getHostname()); webSocket = null; connected = false; updateProperty("System.State", OnOffType.OFF); } @Override @SuppressWarnings("unchecked") public void onMessage(String message) { logger.debug("[{}]: Message received: {}", xbmc.getHostname(), message); Map<String, Object> json; try { json = mapper.readValue(message, Map.class); } catch (JsonParseException e) { logger.error("Error parsing JSON", e); return; } catch (JsonMappingException e) { logger.error("Error mapping JSON", e); return; } catch (IOException e) { logger.error("An I/O error occured while decoding JSON", e); return; } // We only care about certain notifications on the websocket // feed, since all our actual data fetching is done via http try { if (json.containsKey("method")) { String method = (String) json.get("method"); if (method.startsWith("Player.On")) { processPlayerStateChanged(method, json); } else if (method.startsWith("Application.On")) { processApplicationStateChanged(method, json); } else if (method.startsWith("System.On")) { processSystemStateChanged(method, json); } else if (method.startsWith("GUI.OnScreensaver")) { processScreensaverStateChanged(method, json); } } } catch (Exception e) { logger.error("Error handling player state change message", e); } } @Override public void onFragment(String fragment, boolean last) { } } /** * Send a ping to the XBMC host and wait for a 'pong'. */ public void ping() { final JSONRPCPing ping = new JSONRPCPing(client, httpUri); ping.execute(new Runnable() { @Override public void run() { connected = ping.isPong(); } }); } /** * Create a mapping between an item and an xbmc property * * @param itemName * The name of the item which should receive updates * @param property * The property of this xbmc instance, which is to be * watched for changes * */ public void addItem(String itemName, String property) { logger.debug("Mapping: itemname = {} & property = {}", itemName, property); if (!watches.containsKey(itemName)) { watches.put(itemName, property); } } public void updateSystemStatus() { if (connected) { updateProperty("System.State", OnOffType.ON); } else { updateProperty("System.State", OnOffType.OFF); } } /** * Update the status of the current player */ public void updatePlayerStatus() { updatePlayerStatus(false); } /** * Update the status of the current player * * @param updatePolledPropertiesOnly * If updatePolledPropertiesOnly is true, only update the Player properties that need to be polled * If updatePolledPropertiesOnly is false, update the Player state itself as well */ public void updatePlayerStatus(final boolean updatePolledPropertiesOnly) { final PlayerGetActivePlayers activePlayers = new PlayerGetActivePlayers(client, httpUri); activePlayers.execute(new Runnable() { @Override public void run() { if (activePlayers.isPlaying()) { if (!updatePolledPropertiesOnly) { updateState(State.Play); } requestPlayerUpdate(activePlayers.getPlayerId(), updatePolledPropertiesOnly); } else { if (!updatePolledPropertiesOnly) { updateState(State.Stop); } } } }); } private void updateScreenSaverStatus(boolean screenSaverActive) { if (screenSaverActive) { updateProperty(SCREENSAVER_STATE, OnOffType.ON); } else { updateProperty(SCREENSAVER_STATE, OnOffType.OFF); } } public void playerPlayPause() { final PlayerGetActivePlayers activePlayers = new PlayerGetActivePlayers(client, httpUri); activePlayers.execute(new Runnable() { @Override public void run() { PlayerPlayPause playPause = new PlayerPlayPause(client, httpUri); playPause.setPlayerId(activePlayers.getPlayerId()); playPause.execute(); } }); } public void playerStop() { final PlayerGetActivePlayers activePlayers = new PlayerGetActivePlayers(client, httpUri); activePlayers.execute(new Runnable() { @Override public void run() { PlayerStop stop = new PlayerStop(client, httpUri); stop.setPlayerId(activePlayers.getPlayerId()); stop.execute(); } }); } public void showNotification(String title, String message) { final GUIShowNotification showNotification = new GUIShowNotification(client, httpUri); showNotification.setTitle(title); showNotification.setMessage(message); showNotification.execute(); } public void systemShutdown() { final SystemShutdown shutdown = new SystemShutdown(client, httpUri); shutdown.execute(); } public void systemSuspend() { final SystemSuspend suspend = new SystemSuspend(client, httpUri); suspend.execute(); } public void systemHibernate() { final SystemHibernate hibernate = new SystemHibernate(client, httpUri); hibernate.execute(); } public void systemReboot() { final SystemReboot reboot = new SystemReboot(client, httpUri); reboot.execute(); } public void playerOpen(String file) { final PlayerOpen playeropen = new PlayerOpen(client, httpUri); playeropen.setFile(file); playeropen.execute(); } public void inputExecuteAction(String action) { final InputExecuteAction executeAction = new InputExecuteAction(client, httpUri); executeAction.setAction(action); executeAction.execute(); } public void applicationSetVolume(String volume) { final ApplicationSetVolume applicationsetvolume = new ApplicationSetVolume(client, httpUri); if (volume.equals("ON")) { this.volume = new BigDecimal(100); } else if (volume.equals("OFF")) { this.volume = new BigDecimal(0); } else if (volume.equals("DECREASE")) { this.volume = this.volume.subtract(VOLUMESTEP); } else if (volume.equals("INCREASE")) { this.volume = this.volume.add(VOLUMESTEP); } else { try { this.volume = new BigDecimal(volume); } catch (NumberFormatException e) { logger.error("applicationSetVolume cannot parse volume parameter: " + volume); this.volume = BigDecimal.ZERO; } } applicationsetvolume.setVolume(this.volume.intValue()); applicationsetvolume.execute(); } private void processPlayerStateChanged(String method, Map<String, Object> json) { if ("Player.OnPlay".equals(method)) { // get the player id and make a new request for the media details Map<String, Object> params = RpcCall.getMap(json, "params"); Map<String, Object> data = RpcCall.getMap(params, "data"); Map<String, Object> player = RpcCall.getMap(data, "player"); Integer playerId = (Integer) player.get("playerid"); updateState(State.Play); requestPlayerUpdate(playerId); } if ("Player.OnPause".equals(method)) { updateState(State.Pause); } if ("Player.OnStop".equals(method)) { // get the end parameter and send an End state if true Map<String, Object> params = RpcCall.getMap(json, "params"); Map<String, Object> data = RpcCall.getMap(params, "data"); Boolean end = (Boolean) data.get("end"); if (end) { updateState(State.End); } updateState(State.Stop); } } private void processApplicationStateChanged(String method, Map<String, Object> json) { if ("Application.OnVolumeChanged".equals(method)) { // get the player id and make a new request for the media details Map<String, Object> params = RpcCall.getMap(json, "params"); Map<String, Object> data = RpcCall.getMap(params, "data"); Object o = data.get("volume"); PercentType volume = new PercentType(0); if (o instanceof Integer) { volume = new PercentType((Integer) o); } else { if (o instanceof Double) { volume = new PercentType(((Double) o).intValue()); } } updateProperty("Application.Volume", volume); this.volume = new BigDecimal(volume.intValue()); } } private void processSystemStateChanged(String method, Map<String, Object> json) { if ("System.OnQuit".equals(method) || "System.OnSleep".equals(method) || "System.OnRestart".equals(method)) { updateProperty("System.State", OnOffType.OFF); } } private void processScreensaverStateChanged(String method, Map<String, Object> json) { if ("GUI.OnScreensaverDeactivated".equals(method)) { updateScreenSaverStatus(false); } else if ("GUI.OnScreensaverActivated".equals(method)) { updateScreenSaverStatus(true); } } private void updateState(State state) { // sometimes get a Pause immediately after a Stop - so just ignore if (currentState.equals(State.Stop) && state.equals(State.Pause)) { return; } // set the player state watch values updateProperty("Player.State", state.toString()); // if this is a Stop then clear everything else if (state == State.Stop) { for (String property : getPlayerProperties()) { updateProperty(property, (String) null); } } // keep track of our current state currentState = state; } public void requestApplicationUpdate() { final ApplicationGetProperties app = new ApplicationGetProperties(client, httpUri); app.execute(new Runnable() { @Override public void run() { // now update each of the openHAB items for each property volume = new BigDecimal(app.getVolume()); updateProperty("Application.Volume", new PercentType(volume)); } }); } public void requestScreenSaverStateUpdate() { final XBMCGetInfoBooleans xbmc = new XBMCGetInfoBooleans(client, httpUri); xbmc.execute(new Runnable() { @Override public void run() { updateScreenSaverStatus(xbmc.isScreenSaverActive()); } }); } /** * Request an update for the Player properties from XBMC * * @param playerId * The id of the currently active player */ private void requestPlayerUpdate(int playerId) { requestPlayerUpdate(playerId, false); } /** * Request an update for the Player properties from XBMC * * @param playerId * The id of the currently active player * @param updatePolledPropertiesOnly * if updatePolledPropertiesOnly is true, only retrieve the properties that need to be polled * if updatePolledPropertiesOnly is false, retrieve all properties that have items defined for */ private void requestPlayerUpdate(int playerId, boolean updatePolledPropertiesOnly) { // CRIT: if a PVR recording is played in XBMC the playerId is reported as -1 if (playerId == -1) { logger.debug( "[{}]: Invalid playerId ({}) - assume this is a PVR recording playback and update playerId -> 1 (video player)", xbmc.getHostname(), playerId); playerId = 1; } if (playerId < 0 || playerId > 2) { logger.debug("[{}]: Invalid playerId ({}) - must be between 0 and 2 (inclusive)", xbmc.getHostname(), playerId); return; } // get the list of properties we are interested in final List<String> properties = getPlayerProperties(updatePolledPropertiesOnly); if (!properties.isEmpty()) { logger.debug("[{}]: Retrieving properties ({}) for playerId {}", xbmc.getHostname(), properties.size(), playerId); final List<String> propertiesInfo = new ArrayList<String>(); final List<String> propertiesProperties = new ArrayList<String>(); final List<String> propertiesLabels = new ArrayList<String>(); for (String property : properties) { if (property.startsWith("Player.")) { propertiesInfo.add(property); } if (property.startsWith("Property.")) { propertiesProperties.add(property); } if (property.startsWith("Label.")) { propertiesLabels.add(property); } } // make the request for the player item details using GETINFO if (!propertiesInfo.isEmpty()) { final PlayerGetItem item = new PlayerGetItem(client, httpUri); item.setPlayerId(playerId); item.setProperties(propertiesInfo); item.execute(new Runnable() { @Override public void run() { // now update each of the openHAB items for each property for (String property : propertiesInfo) { String value = item.getPropertyValue(property); if (property.equals("Player.Fanart")) { updateFanartUrl(property, value); } else { updateProperty(property, value); } } } }); } // make the request for the player item2 details using GETPROPERTIES if (!propertiesProperties.isEmpty()) { final PlayerGetProperties item2 = new PlayerGetProperties(client, httpUri); item2.setPlayerId(playerId); item2.setProperties(propertiesProperties); item2.execute(new Runnable() { @Override public void run() { // now update each of the openHAB items for each property for (String property : propertiesProperties) { String value = item2.getPropertyValue(property); updateProperty(property, value); } } }); } // make the request for the player item3 details using GETLABELS if (!propertiesLabels.isEmpty()) { final PlayerGetLabels item3 = new PlayerGetLabels(client, httpUri); item3.setProperties(propertiesLabels); item3.execute(new Runnable() { @Override public void run() { // now update each of the openHAB items for each property for (String property : propertiesLabels) { String value = item3.getPropertyValue(property); updateProperty(property, value); } } }); } } } private void updateFanartUrl(final String property, String imagePath) { if (StringUtils.isEmpty(imagePath)) { return; } final FilesPrepareDownload fanart = new FilesPrepareDownload(client, httpUri); fanart.setImagePath(imagePath); fanart.execute(new Runnable() { @Override public void run() { String url = String.format("http://%s:%d/%s", xbmc.getHostname(), xbmc.getPort(), fanart.getPath()); updateProperty(property, url); } }); } private void updateProperty(String property, String value) { StringType stringType = new StringType(value == null ? "" : value); for (Entry<String, String> e : watches.entrySet()) { if (property.equals(e.getValue())) { eventPublisher.postUpdate(e.getKey(), stringType); } } } private void updateProperty(String property, PercentType value) { value = (value == null ? new PercentType(0) : value); for (Entry<String, String> e : watches.entrySet()) { if (property.equals(e.getValue())) { eventPublisher.postUpdate(e.getKey(), value); } } } private void updateProperty(String property, OnOffType value) { value = (value == null ? OnOffType.OFF : value); for (Entry<String, String> e : watches.entrySet()) { if (property.equals(e.getValue())) { eventPublisher.postUpdate(e.getKey(), value); } } } /** * get a distinct list of player properties we have items configured for * * @return * A list of property names */ private List<String> getPlayerProperties() { return getPlayerProperties(false); } /** * get a distinct list of player properties we have items configured for * * @param updatePolledPropertiesOnly * Only get the properties that need to be refreshed by polling if true, * otherwise get all the properties that have items configured for * @return * A list of property names */ private List<String> getPlayerProperties(boolean updatePolledPropertiesOnly) { List<String> properties = new ArrayList<String>(); if (updatePolledPropertiesOnly) { for (String property : watches.values()) { if (properties.contains(property)) { continue; } if (property.equals("Player.State")) { continue; } if (property.startsWith("Player.")) { properties.add(property); } if (property.startsWith("Property.")) { properties.add(property); } if (property.startsWith("Label.")) { properties.add(property); } } } else { for (String property : watches.values()) { if (properties.contains(property)) { continue; } if (property.equals("Player.State")) { continue; } if (property.startsWith("Player.")) { properties.add(property); } if (property.startsWith("Property.")) { properties.add(property); } if (property.startsWith("Label.")) { properties.add(property); } } } return properties; } public void playerOpenPVR(String channelName, int channelgroupid) { final PVRGetChannels pvrGetChannels = new PVRGetChannels(client, httpUri); pvrGetChannels.setChannelgroupid(channelgroupid); pvrGetChannels.setChannelName(channelName); logger.debug("channelName:" + channelName); pvrGetChannels.execute(new Runnable() { @Override public void run() { logger.debug("channelId:" + pvrGetChannels.getChannelId()); if (pvrGetChannels.getChannelId() != null) { PlayerOpen playerOpen = new PlayerOpen(client, httpUri); playerOpen.setChannelId(pvrGetChannels.getChannelId()); playerOpen.execute(); } } }); } }