org.eclipse.smarthome.io.transport.mqtt.MqttBrokerConnection.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.smarthome.io.transport.mqtt.MqttBrokerConnection.java

Source

/**
 * Copyright (c) 2014,2018 Contributors to the Eclipse Foundation
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0
 *
 * SPDX-License-Identifier: EPL-2.0
 */
package org.eclipse.smarthome.io.transport.mqtt;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.commons.lang.StringUtils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.paho.client.mqttv3.IMqttActionListener;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.IMqttToken;
import org.eclipse.paho.client.mqttv3.MqttAsyncClient;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttClientPersistence;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.persist.MqttDefaultFilePersistence;
import org.eclipse.smarthome.config.core.ConfigConstants;
import org.eclipse.smarthome.io.transport.mqtt.internal.ClientCallback;
import org.eclipse.smarthome.io.transport.mqtt.internal.MqttActionAdapterCallback;
import org.eclipse.smarthome.io.transport.mqtt.internal.TopicSubscribers;
import org.eclipse.smarthome.io.transport.mqtt.reconnect.AbstractReconnectStrategy;
import org.eclipse.smarthome.io.transport.mqtt.reconnect.PeriodicReconnectStrategy;
import org.eclipse.smarthome.io.transport.mqtt.sslcontext.AcceptAllCertificatesSSLContext;
import org.eclipse.smarthome.io.transport.mqtt.sslcontext.SSLContextProvider;
import org.osgi.service.cm.ConfigurationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * An MQTTBrokerConnection represents a single client connection to a MQTT broker.
 *
 * When a connection to an MQTT broker is lost, it will try to reconnect every 60 seconds.
 *
 * @author David Graeff - All operations are async now. More flexible sslContextProvider and reconnectStrategy added.
 * @author Davy Vanherbergen
 * @author Markus Rathgeb - added connection state callback
 */
@NonNullByDefault
public class MqttBrokerConnection {
    final Logger logger = LoggerFactory.getLogger(MqttBrokerConnection.class);
    public static final int DEFAULT_KEEPALIVE_INTERVAL = 60;
    public static final int DEFAULT_QOS = 0;

    /**
     * MQTT transport protocols
     */
    public enum Protocol {
        TCP, WEBSOCKETS
    };

    /// Connection parameters
    protected final Protocol protocol;
    protected final String host;
    protected final int port;
    protected final boolean secure;
    protected final String clientId;
    private @Nullable String user;
    private @Nullable String password;

    /// Configuration variables
    private int qos = DEFAULT_QOS;
    private boolean retain = false;
    private @Nullable MqttWillAndTestament lastWill;
    private @Nullable Path persistencePath;
    protected @Nullable AbstractReconnectStrategy reconnectStrategy;
    private SSLContextProvider sslContextProvider = new AcceptAllCertificatesSSLContext();
    private int keepAliveInterval = DEFAULT_KEEPALIVE_INTERVAL;

    /// Runtime variables
    protected @Nullable MqttAsyncClient client;
    protected @Nullable MqttClientPersistence dataStore;
    protected boolean isConnecting = false;
    protected final List<MqttConnectionObserver> connectionObservers = new CopyOnWriteArrayList<>();

    protected final Map<String, TopicSubscribers> subscribers = new HashMap<>();

    // Connection timeout handling
    protected final AtomicReference<@Nullable ScheduledFuture<?>> timeoutFuture = new AtomicReference<>(null);
    protected @Nullable ScheduledExecutorService timeoutExecutor;
    private int timeout = 1200; /* Connection timeout in milliseconds */

    /**
     * Create a IMqttActionListener object for being used as a callback for a connection attempt.
     * The callback will interact with the {@link AbstractReconnectStrategy} as well as inform registered
     * {@link MqttConnectionObserver}s.
     */
    @NonNullByDefault({})
    public class ConnectionCallback implements IMqttActionListener {
        private final MqttBrokerConnection connection;
        private final Runnable cancelTimeoutFuture;
        private CompletableFuture<Boolean> future = new CompletableFuture<Boolean>();

        public ConnectionCallback(MqttBrokerConnection mqttBrokerConnectionImpl) {
            this.connection = mqttBrokerConnectionImpl;
            this.cancelTimeoutFuture = mqttBrokerConnectionImpl::cancelTimeoutFuture;
        }

        @Override
        public void onSuccess(IMqttToken asyncActionToken) {
            cancelTimeoutFuture.run();

            connection.isConnecting = false;
            if (connection.reconnectStrategy != null) {
                connection.reconnectStrategy.connectionEstablished();
            }
            List<CompletableFuture<Boolean>> futures = new ArrayList<>();
            connection.subscribers.forEach((topic, subscriberList) -> {
                futures.add(connection.subscribeRaw(topic));
            });

            // As soon as all subscriptions are performed, turn the connection future complete.
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[futures.size()])).thenRun(() -> {
                future.complete(true);
                connection.connectionObservers
                        .forEach(o -> o.connectionStateChanged(connection.connectionState(), null));
            });
        }

        @Override
        public void onFailure(@Nullable IMqttToken token, @Nullable Throwable error) {
            cancelTimeoutFuture.run();

            final Throwable throwable = (token != null && token.getException() != null) ? token.getException()
                    : error;

            final MqttConnectionState connectionState = connection.connectionState();
            future.complete(false);
            connection.connectionObservers.forEach(o -> o.connectionStateChanged(connectionState, throwable));

            // If we tried to connect via start(), use the reconnect strategy to try it again
            if (connection.isConnecting) {
                connection.isConnecting = false;
                if (connection.reconnectStrategy != null) {
                    connection.reconnectStrategy.lostConnection();
                }
            }
        }

        public CompletableFuture<Boolean> createFuture() {
            future = new CompletableFuture<Boolean>();
            return future;
        }
    }

    /** Client callback object */
    protected ClientCallback clientCallback = new ClientCallback(this, connectionObservers, subscribers);
    /** Connection callback object */
    protected ConnectionCallback connectionCallback;
    /** Action callback object */
    protected IMqttActionListener actionCallback = new MqttActionAdapterCallback();

    /**
     * Create a new TCP MQTT client connection to a server with the given host and port.
     *
     * @param host A host name or address
     * @param port A port or null to select the default port for a secure or insecure connection
     * @param secure A secure connection
     * @param clientId Client id. Each client on a MQTT server has a unique client id. Sometimes client ids are
     *            used for access restriction implementations.
     *            If none is specified, a default is generated. The client id cannot be longer than 65535
     *            characters.
     * @throws IllegalArgumentException If the client id or port is not valid.
     */
    public MqttBrokerConnection(String host, @Nullable Integer port, boolean secure, @Nullable String clientId) {
        this(Protocol.TCP, host, port, secure, clientId);
    }

    /**
     * Create a new MQTT client connection to a server with the given protocol, host and port.
     *
     * @param protocol The transport protocol
     * @param host A host name or address
     * @param port A port or null to select the default port for a secure or insecure connection
     * @param secure A secure connection
     * @param clientId Client id. Each client on a MQTT server has a unique client id. Sometimes client ids are
     *            used for access restriction implementations.
     *            If none is specified, a default is generated. The client id cannot be longer than 65535
     *            characters.
     * @throws IllegalArgumentException If the client id or port is not valid.
     */
    public MqttBrokerConnection(Protocol protocol, String host, @Nullable Integer port, boolean secure,
            @Nullable String clientId) {
        this.protocol = protocol;
        this.host = host;
        this.secure = secure;
        String newClientID = clientId;
        if (newClientID == null) {
            newClientID = MqttClient.generateClientId();
        } else if (newClientID.length() > 65535) {
            throw new IllegalArgumentException("Client ID cannot be longer than 65535 characters");
        }
        if (port != null && (port <= 0 || port > 65535)) {
            throw new IllegalArgumentException("Port is not within a valid range");
        }
        this.port = port != null ? port : (secure ? 8883 : 1883);
        this.clientId = newClientID;
        setReconnectStrategy(new PeriodicReconnectStrategy());
        connectionCallback = new ConnectionCallback(this);
    }

    /**
     * Set the reconnect strategy. The implementor will be called when the connection
     * state to the MQTT broker changed.
     *
     * The reconnect strategy will not be informed if the initial connection to the broker
     * timed out. You need a timeout executor additionally, see {@link #setTimeoutExecutor(Executor)}.
     *
     * @param reconnectStrategy The reconnect strategy. May not be null.
     */
    public void setReconnectStrategy(AbstractReconnectStrategy reconnectStrategy) {
        this.reconnectStrategy = reconnectStrategy;
        reconnectStrategy.setBrokerConnection(this);
    }

    /**
     * @return Return the reconnect strategy
     */
    public @Nullable AbstractReconnectStrategy getReconnectStrategy() {
        return this.reconnectStrategy;
    }

    /**
     * Set a timeout executor. If none is set, you will not be notified of connection timeouts, this
     * also includes a non-firing reconnect strategy. The default executor is none.
     *
     * @param executor One timer will be created when a connection attempt happens
     * @param timeoutInMS Timeout in milliseconds
     */
    public void setTimeoutExecutor(@Nullable ScheduledExecutorService executor, int timeoutInMS) {
        timeoutExecutor = executor;
        this.timeout = timeoutInMS;
    }

    /**
     * Get the MQTT broker protocol
     */
    public Protocol getProtocol() {
        return protocol;
    }

    /**
     * Get the MQTT broker host
     */
    public String getHost() {
        return host;
    }

    /**
     * Get the MQTT broker port
     */
    public int getPort() {
        return port;
    }

    /**
     * Return true if this is or will be an encrypted connection to the broker
     */
    public boolean isSecure() {
        return secure;
    }

    /**
     * Set the optional user name and optional password to use when connecting to the MQTT broker.
     * The connection needs to be restarted for the new settings to take effect.
     *
     * @param user Name to use for connection.
     * @param password The password
     */
    public void setCredentials(@Nullable String user, @Nullable String password) {
        this.user = user;
        this.password = password;
    }

    /**
     * @return connection password.
     */
    public @Nullable String getPassword() {
        return password;
    }

    /**
     * @return optional user name for the MQTT connection.
     */

    public @Nullable String getUser() {
        return user;
    }

    /**
     * @return quality of service level.
     */
    public int getQos() {
        return qos;
    }

    /**
     * Set quality of service. Valid values are 0, 1, 2 and mean
     * "at most once", "at least once" and "exactly once" respectively.
     * The connection needs to be restarted for the new settings to take effect.
     *
     * @param qos level.
     */
    public void setQos(int qos) {
        if (qos >= 0 && qos <= 2) {
            this.qos = qos;
        } else {
            throw new IllegalArgumentException("The quality of service parameter must be >=0 and <=2.");
        }
    }

    /**
     * @return true if newly messages sent to the broker should be retained by the broker.
     */
    public boolean isRetain() {
        return retain;
    }

    /**
     * Set whether newly published messages should be retained by the broker.
     *
     * @param retain true to retain.
     */
    public void setRetain(boolean retain) {
        this.retain = retain;
    }

    /**
     * Return the last will object or null if there is none.
     */
    public @Nullable MqttWillAndTestament getLastWill() {
        return lastWill;
    }

    /**
     * Set the last will object.
     *
     * @param lastWill The last will object or null.
     * @param applyImmediately If true, the connection will stopped and started for the new last-will to take effect
     *            immediately.
     * @throws MqttException
     * @throws ConfigurationException
     */
    public void setLastWill(@Nullable MqttWillAndTestament lastWill, boolean applyImmediately)
            throws ConfigurationException, MqttException {
        this.lastWill = lastWill;
        if (applyImmediately) {
            stop();
            start();
        }
    }

    /**
     * Set the last will object.
     * The connection needs to be restarted for the new settings to take effect.
     *
     * @param lastWill The last will object or null.
     */
    public void setLastWill(@Nullable MqttWillAndTestament lastWill) {
        this.lastWill = lastWill;
    }

    /**
     * Sets the path for the persistence storage.
     *
     * A persistence mechanism is necessary to enable reliable messaging.
     * For messages sent at qualities of service (QoS) 1 or 2 to be reliably delivered, messages must be stored (on both
     * the client and server) until the delivery of the message is complete.
     * If messages are not safely stored when being delivered then a failure in the client or server can result in lost
     * messages.
     * A file persistence storage is used that uses the given path. If the path does not exist it will be created on
     * runtime (if possible). If it is set to {@code null} a implementation specific default path is used.
     *
     * @param persistencePath the path that should be used to store persistent data
     */
    public void setPersistencePath(final @Nullable Path persistencePath) {
        this.persistencePath = persistencePath;
    }

    /**
     * Get client id to use when connecting to the broker.
     *
     * @return value clientId to use.
     */
    public String getClientId() {
        return clientId;
    }

    /**
     * Returns the connection state
     */
    public MqttConnectionState connectionState() {
        if (isConnecting) {
            return MqttConnectionState.CONNECTING;
        }
        return (client != null && client.isConnected()) ? MqttConnectionState.CONNECTED
                : MqttConnectionState.DISCONNECTED;
    }

    /**
     * Set the keep alive interval. The default interval is 60 seconds. If no heartbeat is received within this
     * timeframe, the connection will be considered dead. Set this to a higher value on systems which may not always be
     * able to process the heartbeat in time.
     *
     * @param keepAliveInterval interval in seconds
     */
    public void setKeepAliveInterval(int keepAliveInterval) {
        if (keepAliveInterval <= 0) {
            throw new IllegalArgumentException("Keep alive cannot be <=0");
        }
        this.keepAliveInterval = keepAliveInterval;
    }

    /**
     * Return the keep alive internal in seconds
     */
    public int getKeepAliveInterval() {
        return keepAliveInterval;
    }

    /**
     * Return the ssl context provider.
     */
    public SSLContextProvider getSSLContextProvider() {
        return sslContextProvider;
    }

    /**
     * Set the ssl context provider. The default provider is {@see AcceptAllCertifcatesSSLContext}.
     *
     * @return The ssl context provider. Should not be null, but the ssl context will in fact
     *         only be used if a ssl:// url is given.
     */
    public void setSSLContextProvider(SSLContextProvider sslContextProvider) {
        this.sslContextProvider = sslContextProvider;
    }

    /**
     * Return true if there are subscribers registered via {@link #subscribe(String, MqttMessageSubscriber)}.
     * Call {@link #unsubscribe(String, MqttMessageSubscriber)} or {@link #unsubscribeAll()} if necessary.
     */
    public boolean hasSubscribers() {
        return !subscribers.isEmpty();
    }

    /**
     * Add a new message consumer to this connection. Multiple subscribers with the same
     * topic are allowed. This method will not protect you from adding a subscriber object
     * multiple times!
     *
     * If there is a retained message for the topic, you are guaranteed to receive a callback
     * for each new subscriber, even for the same topic.
     *
     * @param topic The topic to subscribe to.
     * @param subscriber The callback listener for received messages for the given topic.
     * @return Completes with true if successful. Completes with false if not connected yet. Exceptionally otherwise.
     */
    public CompletableFuture<Boolean> subscribe(String topic, MqttMessageSubscriber subscriber) {
        CompletableFuture<Boolean> future = new CompletableFuture<Boolean>();
        synchronized (subscribers) {
            TopicSubscribers subscriberList = subscribers.getOrDefault(topic, new TopicSubscribers(topic));
            subscribers.put(topic, subscriberList);
            subscriberList.add(subscriber);
        }
        final MqttAsyncClient client = this.client;
        if (client == null) {
            future.completeExceptionally(new Exception("No MQTT client"));
            return future;
        }
        if (client.isConnected()) {
            try {
                client.subscribe(topic, qos, future, actionCallback);
            } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
                future.completeExceptionally(e);
            }
        } else {
            // The subscription will be performed on connecting.
            future.complete(false);
        }
        return future;
    }

    /**
     * Subscribes to a topic on the given connection, but does not alter the subscriber list.
     *
     * @param topic The topic to subscribe to.
     * @return Completes with true if successful. Exceptionally otherwise.
     */
    protected CompletableFuture<Boolean> subscribeRaw(String topic) {
        logger.trace("subscribeRaw message consumer for topic '{}' from broker '{}'", topic, host);
        CompletableFuture<Boolean> future = new CompletableFuture<Boolean>();
        try {
            MqttAsyncClient client = this.client;
            if (client != null && client.isConnected()) {
                client.subscribe(topic, qos, future, actionCallback);
            } else {
                future.complete(false);
            }
        } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
            logger.info("Error subscribing to topic {}", topic, e);
            future.completeExceptionally(e);
        }
        return future;
    }

    /**
     * Remove a previously registered consumer from this connection.
     * If no more consumers are registered for a topic, the topic will be unsubscribed from.
     *
     * @param topic The topic to unsubscribe from.
     * @param subscriber The callback listener to remove.
     * @return Completes with true if successful. Exceptionally otherwise.
     */
    @SuppressWarnings({ "null", "unused" })
    public CompletableFuture<Boolean> unsubscribe(String topic, MqttMessageSubscriber subscriber) {

        synchronized (subscribers) {
            final @Nullable List<MqttMessageSubscriber> list = subscribers.get(topic);
            if (list == null) {
                return CompletableFuture.completedFuture(true);
            }
            list.remove(subscriber);
            if (!list.isEmpty()) {
                return CompletableFuture.completedFuture(true);
            }
            // Remove from subscriber list
            subscribers.remove(topic);
            // No more subscribers to this topic. Unsubscribe topic on the broker
            MqttAsyncClient client = this.client;
            if (client != null) {
                return unsubscribeRaw(client, topic);
            } else {
                return CompletableFuture.completedFuture(false);
            }
        }
    }

    /**
     * Unsubscribes from a topic on the given connection, but does not alter the subscriber list.
     *
     * @param client The client connection
     * @param topic The topic to unsubscribe from
     * @return Completes with true if successful. Completes with false if no broker connection is established.
     *         Exceptionally otherwise.
     */
    protected CompletableFuture<Boolean> unsubscribeRaw(MqttAsyncClient client, String topic) {
        logger.trace("Unsubscribing message consumer for topic '{}' from broker '{}'", topic, host);
        CompletableFuture<Boolean> future = new CompletableFuture<Boolean>();
        try {
            if (client.isConnected()) {
                client.unsubscribe(topic, future, actionCallback);
            } else {
                future.complete(false);
            }
        } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
            logger.info("Error unsubscribing topic from broker", e);
            future.completeExceptionally(e);
        }
        return future;
    }

    /**
     * Add a new connection observer to this connection.
     *
     * @param connectionObserver The connection observer that should be added.
     */
    public synchronized void addConnectionObserver(MqttConnectionObserver connectionObserver) {
        connectionObservers.add(connectionObserver);
    }

    /**
     * Remove a previously registered connection observer from this connection.
     *
     * @param connectionObserver The connection observer that should be removed.
     */
    public synchronized void removeConnectionObserver(MqttConnectionObserver connectionObserver) {
        connectionObservers.remove(connectionObserver);
    }

    /**
     * Return true if there are connection observers registered via addConnectionObserver().
     */
    public boolean hasConnectionObservers() {
        return !connectionObservers.isEmpty();
    }

    /**
     * Create a MqttConnectOptions object using the fields of this MqttBrokerConnection instance.
     * Package local, for testing.
     */
    MqttConnectOptions createMqttOptions() throws ConfigurationException {
        MqttConnectOptions options = new MqttConnectOptions();

        if (!StringUtils.isBlank(user)) {
            options.setUserName(user);
        }
        if (!StringUtils.isBlank(password) && password != null) {
            options.setPassword(password.toCharArray());
        }
        if (secure) {
            options.setSocketFactory(sslContextProvider.getContext().getSocketFactory());
        }

        if (lastWill != null) {
            MqttWillAndTestament lastWill = this.lastWill; // Make eclipse happy
            options.setWill(lastWill.getTopic(), lastWill.getPayload(), lastWill.getQos(), lastWill.isRetain());
        }

        options.setKeepAliveInterval(keepAliveInterval);
        return options;
    }

    /**
     * This will establish a connection to the MQTT broker and if successful, notify all
     * publishers and subscribers that the connection has become active. This method will
     * do nothing if there is already an active connection.
     *
     * @return Returns a future that completes with true if already connected or connecting,
     *         completes with false if a connection timeout has happened and completes exceptionally otherwise.
     */
    public CompletableFuture<Boolean> start() {
        // We don't want multiple concurrent threads to start a connection
        synchronized (this) {
            if (connectionState() != MqttConnectionState.DISCONNECTED) {
                return CompletableFuture.completedFuture(true);
            }

            // Perform the connection attempt
            isConnecting = true;
            connectionObservers.forEach(o -> o.connectionStateChanged(MqttConnectionState.CONNECTING, null));
        }

        // Ensure the reconnect strategy is started
        if (reconnectStrategy != null) {
            reconnectStrategy.start();
        }

        // Close client if there is still one existing
        if (client != null) {
            try {
                client.close();
            } catch (org.eclipse.paho.client.mqttv3.MqttException ignore) {
            }
            client = null;
        }

        CompletableFuture<Boolean> future = connectionCallback.createFuture();

        StringBuilder serverURI = new StringBuilder();
        switch (protocol) {
        case TCP:
            serverURI.append(secure ? "ssl://" : "tcp://");
            break;
        case WEBSOCKETS:
            serverURI.append(secure ? "wss://" : "ws://");
            break;
        default:
            future.completeExceptionally(new ConfigurationException("protocol", "Protocol unknown"));
            return future;
        }
        serverURI.append(host);
        serverURI.append(":");
        serverURI.append(port);

        // Storage
        Path persistencePath = this.persistencePath;
        if (persistencePath == null) {
            persistencePath = Paths.get(ConfigConstants.getUserDataFolder()).resolve("mqtt").resolve(host);
        }
        try {
            persistencePath = Files.createDirectories(persistencePath);
        } catch (IOException e) {
            future.completeExceptionally(new MqttException(e));
            return future;
        }
        MqttDefaultFilePersistence _dataStore = new MqttDefaultFilePersistence(persistencePath.toString());

        // Create the client
        MqttAsyncClient _client;
        try {
            _client = createClient(serverURI.toString(), clientId, _dataStore);
        } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
            future.completeExceptionally(new MqttException(e));
            return future;
        }

        // Assign to object
        this.client = _client;
        this.dataStore = _dataStore;

        // Connect
        _client.setCallback(clientCallback);
        try {
            _client.connect(createMqttOptions(), null, connectionCallback);
            logger.info("Starting MQTT broker connection to '{}' with clientid {} and file store '{}'", host,
                    getClientId(), persistencePath);
        } catch (org.eclipse.paho.client.mqttv3.MqttException | ConfigurationException e) {
            future.completeExceptionally(new MqttException(e));
            return future;
        }

        // Connect timeout
        ScheduledExecutorService executor = timeoutExecutor;
        if (executor != null) {
            final ScheduledFuture<?> timeoutFuture = this.timeoutFuture
                    .getAndSet(executor.schedule(() -> connectionCallback.onFailure(null, new TimeoutException()),
                            timeout, TimeUnit.MILLISECONDS));
            if (timeoutFuture != null) {
                timeoutFuture.cancel(false);
            }
        }
        return future;
    }

    /**
     * Encapsulates the creation of the paho MqttAsyncClient
     *
     * @param serverURI A paho uri like ssl://host:port, tcp://host:port, ws[s]://host:port
     * @param clientId the mqtt client ID
     * @param dataStore The datastore to save qos!=0 messages until they are delivered.
     * @return Returns a valid MqttAsyncClient
     * @throws org.eclipse.paho.client.mqttv3.MqttException
     */
    protected MqttAsyncClient createClient(String serverURI, String clientId, MqttClientPersistence dataStore)
            throws org.eclipse.paho.client.mqttv3.MqttException {
        return new MqttAsyncClient(serverURI, clientId, dataStore);
    }

    /**
     * After a successful disconnect, the underlying library objects need to be closed and connection observers want to
     * be notified.
     *
     * @param v A passthrough boolean value
     * @return Returns the value of the parameter v.
     */
    protected boolean finalizeStopAfterDisconnect(boolean v) {
        if (client != null) {
            try {
                client.close();
            } catch (Exception ignore) {
            }
        }
        client = null;
        if (dataStore != null) {
            try {
                dataStore.close();
            } catch (Exception ignore) {
            }
            dataStore = null;
        }
        connectionObservers.forEach(o -> o.connectionStateChanged(MqttConnectionState.DISCONNECTED, null));
        return v;
    }

    /**
     * Unsubscribe from all topics
     *
     * @return Returns a future that completes as soon as all subscriptions have been canceled.
     */
    public CompletableFuture<Void> unsubscribeAll() {
        MqttAsyncClient client = this.client;
        List<CompletableFuture<Boolean>> futures = new ArrayList<>();
        if (client != null) {
            subscribers.forEach((topic, subList) -> {
                futures.add(unsubscribeRaw(client, topic));
            });
            subscribers.clear();
        }
        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[futures.size()]));
    }

    /**
     * Unsubscribes from all subscribed topics, stops the reconnect strategy, disconnect and close the client.
     *
     * You can re-establish a connection calling {@link #start()} again. Do not call start, before the closing process
     * has finished completely.
     *
     * @return Returns a future that completes as soon as the disconnect process has finished.
     */
    public CompletableFuture<Boolean> stop() {
        MqttAsyncClient client = this.client;
        if (client == null) {
            return CompletableFuture.completedFuture(true);
        }

        logger.trace("Closing the MQTT broker connection '{}'", host);

        // Abort a connection attempt
        isConnecting = false;

        // Cancel the timeout future. If stop is called we can safely assume there is no interest in a connection
        // anymore.
        cancelTimeoutFuture();

        // Stop the reconnect strategy
        if (reconnectStrategy != null) {
            reconnectStrategy.stop();
        }

        CompletableFuture<Boolean> future = new CompletableFuture<Boolean>();
        // Close connection
        if (client.isConnected()) {
            // We need to thread change here. Because paho does not allow to disconnect within a callback method
            unsubscribeAll().thenRunAsync(() -> {
                try {
                    client.disconnect(100).waitForCompletion(100);
                    if (client.isConnected()) {
                        client.disconnectForcibly();
                    }
                    future.complete(true);
                } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
                    logger.debug("Error while closing connection to broker", e);
                    future.complete(false);
                }
            });
        } else {
            future.complete(true);
        }

        return future.thenApply(this::finalizeStopAfterDisconnect);
    }

    /**
     * Publish a message to the broker with the given QoS and retained flag.
     *
     * @param topic The topic
     * @param payload The message payload
     * @param qos The quality of service for this message
     * @param retain Set to true to retain the message on the broker
     * @param listener A listener to be notified of success or failure of the delivery.
     */
    public void publish(String topic, byte[] payload, int qos, boolean retain, MqttActionCallback listener) {
        MqttAsyncClient client_ = client;
        if (client_ == null) {
            listener.onFailure(topic, new MqttException(0));
            return;
        }
        try {
            IMqttDeliveryToken deliveryToken = client_.publish(topic, payload, qos, retain, listener,
                    actionCallback);
            logger.debug("Publishing message {} to topic '{}'", deliveryToken.getMessageId(), topic);
        } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
            listener.onFailure(topic, new MqttException(e));
        }
    }

    /**
     * Publish a message to the broker.
     *
     * @param topic The topic
     * @param payload The message payload
     * @param listener A listener to be notified of success or failure of the delivery.
     */
    public void publish(String topic, byte[] payload, MqttActionCallback listener) {
        publish(topic, payload, qos, retain, listener);
    }

    /**
     * Publish a message to the broker.
     *
     * @param topic The topic
     * @param payload The message payload
     * @return Returns a future that completes with a result of true if the publishing succeeded and completes
     *         exceptionally on an error or with a result of false if no broker connection is established.
     */
    public CompletableFuture<Boolean> publish(String topic, byte[] payload) {
        return publish(topic, payload, qos, retain);
    }

    /**
     * Publish a message to the broker with the given QoS and retained flag.
     *
     * @param topic The topic
     * @param payload The message payload
     * @param qos The quality of service for this message
     * @param retain Set to true to retain the message on the broker
     * @param listener An optional listener to be notified of success or failure of the delivery.
     * @return Returns a future that completes with a result of true if the publishing succeeded and completes
     *         exceptionally on an error or with a result of false if no broker connection is established.
     */
    public CompletableFuture<Boolean> publish(String topic, byte[] payload, int qos, boolean retain) {
        MqttAsyncClient client = this.client;
        if (client == null) {
            return CompletableFuture.completedFuture(false);
        }
        // publish message asynchronously
        CompletableFuture<Boolean> f = new CompletableFuture<Boolean>();
        try {
            client.publish(topic, payload, qos, retain, f, actionCallback);
        } catch (org.eclipse.paho.client.mqttv3.MqttException e) {
            f.completeExceptionally(new MqttException(e));
        }
        return f;
    }

    /**
     * The connection process is limited by a timeout, realized with a {@link CompletableFuture}. Cancel that future
     * now, if it exists.
     */
    protected void cancelTimeoutFuture() {
        final ScheduledFuture<?> timeoutFuture = this.timeoutFuture.getAndSet(null);
        if (timeoutFuture != null) {
            timeoutFuture.cancel(false);
        }
    }
}