package net.whistlingfish.harmony;
import static java.lang.String.format;
import static net.whistlingfish.harmony.protocol.MessageHoldAction.HoldStatus.*;
import java.io.IOException;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import org.jivesoftware.smack.ConnectionListener;
import org.jivesoftware.smack.SmackException;
import org.jivesoftware.smack.SmackException.NoResponseException;
import org.jivesoftware.smack.SmackException.NotConnectedException;
import org.jivesoftware.smack.StanzaCollector;
import org.jivesoftware.smack.StanzaListener;
import org.jivesoftware.smack.XMPPConnection;
import org.jivesoftware.smack.XMPPConnection.FromMode;
import org.jivesoftware.smack.XMPPException;
import org.jivesoftware.smack.XMPPException.XMPPErrorException;
import org.jivesoftware.smack.filter.StanzaFilter;
import org.jivesoftware.smack.packet.Bind;
import org.jivesoftware.smack.packet.ExtensionElement;
import org.jivesoftware.smack.packet.Stanza;
import org.jivesoftware.smack.provider.ProviderManager;
import org.jivesoftware.smack.sasl.SASLMechanism;
import org.jivesoftware.smack.sm.predicates.ForEveryStanza;
import org.jivesoftware.smack.tcp.XMPPTCPConnection;
import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration;
import org.jxmpp.jid.parts.Resourcepart;
import org.jxmpp.stringprep.XmppStringprepException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.inject.Guice;
import com.google.inject.Injector;
import net.whistlingfish.harmony.config.Activity;
import net.whistlingfish.harmony.config.Device;
import net.whistlingfish.harmony.config.HarmonyConfig;
import net.whistlingfish.harmony.protocol.EmptyIncrementedIdReplyFilter;
import net.whistlingfish.harmony.protocol.HarmonyBindIQProvider;
import net.whistlingfish.harmony.protocol.HarmonyXMPPTCPConnection;
import net.whistlingfish.harmony.protocol.LoginToken;
import net.whistlingfish.harmony.protocol.MessageAuth.AuthReply;
import net.whistlingfish.harmony.protocol.MessageAuth.AuthRequest;
import net.whistlingfish.harmony.protocol.MessageGetConfig.GetConfigReply;
import net.whistlingfish.harmony.protocol.MessageGetConfig.GetConfigRequest;
import net.whistlingfish.harmony.protocol.MessageGetCurrentActivity.GetCurrentActivityReply;
import net.whistlingfish.harmony.protocol.MessageGetCurrentActivity.GetCurrentActivityRequest;
import net.whistlingfish.harmony.protocol.MessageHoldAction.HoldActionRequest;
import net.whistlingfish.harmony.protocol.MessagePing.PingReply;
import net.whistlingfish.harmony.protocol.MessagePing.PingRequest;
import net.whistlingfish.harmony.protocol.MessageStartActivity.StartActivityReply;
import net.whistlingfish.harmony.protocol.MessageStartActivity.StartActivityRequest;
import net.whistlingfish.harmony.protocol.OAReplyFilter;
import net.whistlingfish.harmony.protocol.OAStanza;
public class HarmonyClient {
private static final Logger logger = LoggerFactory.getLogger(HarmonyClient.class);
public static final int DEFAULT_REPLY_TIMEOUT = 30_000;
public static final int START_ACTIVITY_REPLY_TIMEOUT = 30_000;
private static final int DEFAULT_PORT = 5222;
private static final String DEFAULT_XMPP_USER = "guest@connect.logitech.com/gatorade.";
private static final String DEFAULT_XMPP_PASSWORD = "gatorade.";
private boolean smackConfigured;
private HarmonyXMPPTCPConnection connection;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private ScheduledFuture<?> heartbeat;
/*
* To prevent timeouts when different threads send a message and expect a response, create a lock that only allows a
* single thread at a time to perform a send/receive action.
*/
private ReentrantLock messageLock = new ReentrantLock();
private HarmonyConfig config;
private Activity currentActivity;
private Set<ActivityChangeListener> activityChangeListeners = new HashSet<>();
public static HarmonyClient getInstance() {
Injector injector = Guice.createInjector(new HarmonyClientModule());
return injector.getInstance(HarmonyClient.class);
}
private void configureSmack() {
if (!smackConfigured) {
ProviderManager.addIQProvider(Bind.ELEMENT, Bind.NAMESPACE, new HarmonyBindIQProvider());
smackConfigured = true;
}
}
public void disconnect() {
if (connection != null) {
connection.disconnect();
}
if (heartbeat != null) {
heartbeat.cancel(false);
}
}
public void connect(String host) {
connect(host, null);
}
public void connect(String host, LoginToken loginToken) {
configureSmack();
XMPPTCPConnectionConfiguration connectionConfig = createConnectionConfig(host, DEFAULT_PORT);
HarmonyXMPPTCPConnection authConnection = new HarmonyXMPPTCPConnection(connectionConfig);
try {
addPacketLogging(authConnection, "auth");
authConnection.connect();
authConnection.login(DEFAULT_XMPP_USER, DEFAULT_XMPP_PASSWORD, Resourcepart.from("auth"));
authConnection.setFromMode(FromMode.USER);
AuthRequest sessionRequest = createSessionRequest(loginToken);
AuthReply oaResponse = sendOAStanza(authConnection, sessionRequest, AuthReply.class);
authConnection.disconnect();
connection = new HarmonyXMPPTCPConnection(connectionConfig);
addPacketLogging(connection, "main");
connection.connect();
connection.login(oaResponse.getUsername(), oaResponse.getPassword(), Resourcepart.from("main"));
connection.setFromMode(FromMode.USER);
connection.addConnectionListener(new ConnectionListener() {
@Override
public void reconnectionSuccessful() {
getCurrentActivity();
}
@Override
public void connected(XMPPConnection connection) {
}
@Override
public void authenticated(XMPPConnection connection, boolean resumed) {
}
@Override
public void connectionClosed() {
}
@Override
public void connectionClosedOnError(Exception e) {
}
@Override
public void reconnectingIn(int seconds) {
}
@Override
public void reconnectionFailed(Exception e) {
}
});
heartbeat = scheduler.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
try {
if (connection.isConnected()) {
sendPing();
}
} catch (Exception e) {
logger.warn("Send heartbeat failed", e);
}
}
}, 30, 30, TimeUnit.SECONDS);
monitorActivityChanges();
getCurrentActivity();
} catch (InterruptedException | XMPPException | SmackException | IOException e) {
throw new RuntimeException("Failed communicating with Harmony Hub", e);
}
}
private void monitorActivityChanges() {
connection.addSyncStanzaListener(new StanzaListener() {
@Override
public void processStanza(Stanza stanza) throws NotConnectedException {
updateCurrentActivity(getCurrentActivity());
}
}, new StanzaFilter() {
@Override
public boolean accept(Stanza stanza) {
ExtensionElement event = stanza.getExtension("event", "connect.logitech.com");
if (event == null) {
return false;
}
return true;
}
});
}
private synchronized Activity updateCurrentActivity(Activity activity) {
if (currentActivity != activity) {
currentActivity = activity;
for (ActivityChangeListener listener : activityChangeListeners) {
logger.debug("listener[{}] notified: {}", listener, currentActivity);
listener.activityStarted(currentActivity);
}
}
return currentActivity;
}
public void addListener(HarmonyHubListener listener) {
listener.addTo(this);
}
public synchronized void addListener(ActivityChangeListener listener) {
logger.debug("listener[{}] added", listener);
activityChangeListeners.add(listener);
if (currentActivity != null) {
logger.debug("listener[{}] notified: {}", listener, currentActivity);
listener.activityStarted(currentActivity);
}
}
public void removeListener(HarmonyHubListener listener) {
listener.removeFrom(this);
}
public void removeListener(ActivityChangeListener activityChangeListener) {
activityChangeListeners.remove(activityChangeListener);
}
private Stanza sendOAStanza(XMPPTCPConnection authConnection, OAStanza stanza) {
return sendOAStanza(authConnection, stanza, DEFAULT_REPLY_TIMEOUT);
}
private Stanza sendOAStanza(XMPPTCPConnection authConnection, OAStanza stanza, long replyTimeout) {
StanzaCollector collector = authConnection
.createStanzaCollector(new EmptyIncrementedIdReplyFilter(stanza, authConnection));
messageLock.lock();
try {
authConnection.sendStanza(stanza);
return getNextStanzaSkipContinues(collector, replyTimeout, authConnection);
} catch (InterruptedException | SmackException | XMPPErrorException e) {
throw new RuntimeException("Failed communicating with Harmony Hub", e);
} finally {
messageLock.unlock();
collector.cancel();
}
}
private <R extends OAStanza> R sendOAStanza(XMPPTCPConnection authConnection, OAStanza stanza,
Class<R> replyClass) {
return sendOAStanza(authConnection, stanza, replyClass, DEFAULT_REPLY_TIMEOUT);
}
private <R extends OAStanza> R sendOAStanza(XMPPTCPConnection authConnection, OAStanza stanza, Class<R> replyClass,
long replyTimeout) {
StanzaCollector collector = authConnection.createStanzaCollector(new OAReplyFilter(stanza, authConnection));
messageLock.lock();
try {
authConnection.sendStanza(stanza);
return replyClass.cast(getNextStanzaSkipContinues(collector, replyTimeout, authConnection));
} catch (InterruptedException | SmackException | XMPPErrorException e) {
throw new RuntimeException("Failed communicating with Harmony Hub", e);
} finally {
messageLock.unlock();
collector.cancel();
}
}
private Stanza getNextStanzaSkipContinues(StanzaCollector collector, long replyTimeout,
XMPPTCPConnection authConnection) throws InterruptedException, NoResponseException, XMPPErrorException {
while (true) {
Stanza reply = collector.nextResult(replyTimeout);
if (reply == null) {
throw NoResponseException.newWith(authConnection, collector);
}
if (reply instanceof OAStanza && ((OAStanza) reply).isContinuePacket()) {
continue;
}
return reply;
}
}
private void addPacketLogging(XMPPTCPConnection authConnection, final String prefix) {
authConnection.addPacketSendingListener(new StanzaListener() {
@Override
public void processStanza(Stanza stanza) {
logger.trace("{}>>> {}", prefix, stanza.toXML().toString().replaceAll("\n", ""));
}
}, ForEveryStanza.INSTANCE);
authConnection.addSyncStanzaListener(new StanzaListener() {
@Override
public void processStanza(Stanza stanza) throws NotConnectedException, InterruptedException {
logger.trace("<<<{} {}", prefix, stanza.toXML().toString().replaceAll("\n", ""));
}
}, ForEveryStanza.INSTANCE);
}
private XMPPTCPConnectionConfiguration createConnectionConfig(String host, int port) {
try {
return XMPPTCPConnectionConfiguration.builder().setHost(host).setPort(port).setXmppDomain(host)
.addEnabledSaslMechanism(SASLMechanism.PLAIN).build();
} catch (XmppStringprepException e) {
throw new RuntimeException(e);
}
}
public HarmonyConfig getConfig() {
if (config == null) {
config = HarmonyConfig
.parse(sendOAStanza(connection, new GetConfigRequest(), GetConfigReply.class).getConfig());
}
return config;
}
private AuthRequest createSessionRequest(LoginToken loginToken) {
return new AuthRequest(loginToken);
}
public void sendPing() {
sendOAStanza(connection, new PingRequest(), PingReply.class);
}
public void pressButton(int deviceId, String button) {
sendOAStanza(connection, new HoldActionRequest(deviceId, button, PRESS));
try {
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
sendOAStanza(connection, new HoldActionRequest(deviceId, button, RELEASE));
}
public void pressButton(String deviceName, String button) {
Device device = getConfig().getDeviceByName(deviceName);
if (device == null) {
throw new IllegalArgumentException(format("Unknown device '%s'", deviceName));
}
pressButton(device.getId(), button);
}
public Map<Integer, String> getDeviceLabels() {
return getConfig().getDeviceLabels();
}
public Activity getCurrentActivity() {
GetCurrentActivityReply reply = sendOAStanza(connection, new GetCurrentActivityRequest(),
GetCurrentActivityReply.class);
HarmonyConfig config = getConfig();
return updateCurrentActivity(config.getActivityById(reply.getResult()));
}
public void startActivity(int activityId) {
if (getConfig().getActivityById(activityId) == null) {
throw new IllegalArgumentException(format("Unknown activity '%d'", activityId));
}
sendOAStanza(connection, new StartActivityRequest(activityId), StartActivityReply.class,
START_ACTIVITY_REPLY_TIMEOUT);
}
public void startActivityByName(String label) {
Activity activity = getConfig().getActivityByName(label);
if (activity == null) {
throw new IllegalArgumentException(format("Unknown activity '%s'", label));
}
startActivity(activity.getId());
}
}