org.openhab.io.neeo.internal.NeeoApi.java Source code

Java tutorial

Introduction

Here is the source code for org.openhab.io.neeo.internal.NeeoApi.java

Source

/**
 * Copyright (c) 2010-2019 Contributors to the openHAB project
 *
 * See the NOTICE file(s) distributed with this work for additional
 * information.
 *
 * 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.openhab.io.neeo.internal;

import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URL;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
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.jetty.http.HttpStatus;
import org.eclipse.smarthome.core.common.ThreadPoolManager;
import org.eclipse.smarthome.core.id.InstanceUUID;
import org.eclipse.smarthome.core.net.HttpServiceUtil;
import org.openhab.io.neeo.internal.models.NeeoAdapterRegistration;
import org.openhab.io.neeo.internal.models.NeeoRecipe;
import org.openhab.io.neeo.internal.models.NeeoRecipeUrls;
import org.openhab.io.neeo.internal.models.NeeoSystemInfo;
import org.openhab.io.neeo.internal.net.HttpRequest;
import org.openhab.io.neeo.internal.net.HttpResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

/**
 * The class provides the API for communicating with a NEEO brain
 *
 * @author Tim Roberts - Initial Contribution
 */
@NonNullByDefault
public class NeeoApi implements AutoCloseable {

    /** The property name used for connection change notifications */
    public static final String CONNECTED = "connected";

    /** The logger */
    private final Logger logger = LoggerFactory.getLogger(NeeoApi.class);

    /** The gson used in communications */
    private static final Gson GSON = NeeoUtil.createGson();

    /** The brain's IP address */
    private final String brainIpAddress;

    /** The URL of the brain */
    private final String brainUrl;

    /** The brain identifier */
    private final String brainId;

    /** The name of the brain */
    private final String brainName;

    /** The known device keys on the brain. */
    private final NeeoDeviceKeys deviceKeys;

    /** The brain's system information */
    private final NeeoSystemInfo systemInfo;

    /** The property change support */
    private final PropertyChangeSupport propertySupport = new PropertyChangeSupport(this);

    /** The interval, in seconds, to do a check status call */
    private final int checkStatusInterval;

    /** The callback url registered with the brain */
    private final URL callbackUrl;

    /** The ansi escape color conversion */
    private static final String[] ANSICOLORS = new String[] { "black", "red", "green", "yellow", "blue", "magenta",
            "cyan", "white" };

    /** The scheduler used to schedule tasks */
    private final ScheduledExecutorService scheduler = ThreadPoolManager
            .getScheduledPool(NeeoConstants.THREAD_POOL_NAME);

    /** The connection task (not-null when connecting, null otherwise) */
    private final AtomicReference<@Nullable Future<?>> connect = new AtomicReference<>(null);

    /** The check status task (not-null when connecting, null otherwise) */
    private final AtomicReference<@Nullable ScheduledFuture<?>> checkStatus = new AtomicReference<>(null);

    /** The {@link HttpRequest} used for making requests */
    private final AtomicReference<HttpRequest> request = new AtomicReference<>(new HttpRequest());

    /** Whether the brain is currently connected */
    private final AtomicBoolean connected = new AtomicBoolean(false);

    /**
     * Constructs the APi from the given IP address, brain identifier and {@link ServiceContext}
     *
     * @param ipAddress the non-empty ip address
     * @param brainId   the non-empty brain id
     * @param context   the non-null {@link ServiceContext}
     * @throws IOException if an exception occurs connecting to the brain
     */
    public NeeoApi(String ipAddress, String brainId, ServiceContext context) throws IOException {
        NeeoUtil.requireNotEmpty(ipAddress, "ipAddress cannot be empty");
        NeeoUtil.requireNotEmpty(brainId, "brainId cannot be empty");
        Objects.requireNonNull(context, "context cannot be null");

        this.brainIpAddress = ipAddress;
        this.brainId = brainId;
        this.brainUrl = NeeoConstants.PROTOCOL + (ipAddress.startsWith("/") ? ipAddress.substring(1) : ipAddress)
                + ":" + NeeoConstants.DEFAULT_BRAIN_PORT;
        deviceKeys = new NeeoDeviceKeys(brainUrl);

        this.systemInfo = getSystemInfo(ipAddress);

        String name = brainId;
        try (HttpRequest request = new HttpRequest()) {
            logger.debug("Getting existing device mappings from {}{}", brainUrl, NeeoConstants.PROJECTS_HOME);
            final HttpResponse resp = request.sendGetCommand(brainUrl + NeeoConstants.PROJECTS_HOME);
            if (resp.getHttpCode() != HttpStatus.OK_200) {
                throw resp.createException();
            }

            final JsonParser parser = new JsonParser();
            final JsonObject root = parser.parse(resp.getContent()).getAsJsonObject();
            for (Map.Entry<String, JsonElement> room : root.getAsJsonObject("rooms").entrySet()) {
                final JsonObject roomObj = (JsonObject) room.getValue();

                if (roomObj.get("hasController").getAsBoolean()) {
                    name = roomObj.get("name").getAsString();
                    break;
                }
            }
        }
        this.brainName = name;

        final Object statusCheck = context.getComponentContext().getProperties()
                .get(NeeoConstants.CFG_CHECKSTATUSINTERVAL);
        int checkStatus = 10;
        if (statusCheck != null) {
            try {
                checkStatus = Integer.parseInt(statusCheck.toString());
            } catch (NumberFormatException e) {
                logger.debug("{} was not a valid integer, defaulting to 10: {}",
                        NeeoConstants.CFG_CHECKSTATUSINTERVAL, statusCheck);
            }
        }
        checkStatusInterval = checkStatus;

        // Get the port the service is listening on
        final int port = HttpServiceUtil.getHttpServicePort(context.getComponentContext().getBundleContext());

        final String primaryAddress = context.getNetworkAddressService().getPrimaryIpv4HostAddress();
        if (primaryAddress == null) {
            throw new IOException(
                    "Unable to create a callback URL because there is no primary address specified (please set the primary address in the configuration)");
        }

        callbackUrl = new URL("http://" + primaryAddress + ":"
                + (port == -1 ? NeeoConstants.DEFAULT_OPENHAB_PORT : port) + NeeoUtil.getServletUrl(brainId));

    }

    /**
     * Returns the brain's system information
     *
     * @return a non-null system information
     */
    public NeeoSystemInfo getSystemInfo() {
        return this.systemInfo;
    }

    /**
     * Helper method to retrieve the {@link NeeoSystemInfo} from the given ip address
     *
     * @param ipAddress the non-empty ip address
     * @return the non-null {@link NeeoSystemInfo} for the address
     * @throws IOException Signals that an I/O exception has occurred or the URL is not a brain
     */
    public static NeeoSystemInfo getSystemInfo(String ipAddress) throws IOException {
        NeeoUtil.requireNotEmpty(ipAddress, "ipAddress cannot be empty");
        final String sysInfo = NeeoConstants.PROTOCOL
                + (ipAddress.startsWith("/") ? ipAddress.substring(1) : ipAddress) + ":"
                + NeeoConstants.DEFAULT_BRAIN_PORT + NeeoConstants.SYSTEMINFO;

        try (HttpRequest req = new HttpRequest()) {
            final HttpResponse res = req.sendGetCommand(sysInfo);
            if (res.getHttpCode() == HttpStatus.OK_200) {
                return GSON.fromJson(res.getContent(), NeeoSystemInfo.class);
            } else {
                throw res.createException();
            }
        }
    }

    /**
     * Returns the name of the brain
     *
     * @return a non-null, non-empty brain name
     */
    public String getBrainName() {
        return brainName;
    }

    /**
     * Helper method to blink the LED on the given brain IP address
     *
     * @throws IOException Signals that an I/O exception has occurred or the URL is not a brain
     */
    public void blinkLed() throws IOException {
        final String identBrain = NeeoConstants.PROTOCOL
                + (brainIpAddress.startsWith("/") ? brainIpAddress.substring(1) : brainIpAddress) + ":"
                + NeeoConstants.DEFAULT_BRAIN_PORT + NeeoConstants.IDENTBRAIN;

        final HttpRequest rqst = request.get();
        final HttpResponse res = rqst.sendGetCommand(identBrain);
        if (res.getHttpCode() != HttpStatus.OK_200) {
            throw res.createException();
        }
    }

    /**
     * Helper method to get the log file from the brain, convert the ANSI escaped result to HTML and return it
     *
     * @return a non-empty string containing the log file from the brain
     *
     * @throws IOException Signals that an I/O exception has occurred or the URL is not a brain
     */
    public String getLog() throws IOException {
        final String logUrl = NeeoConstants.PROTOCOL
                + (brainIpAddress.startsWith("/") ? brainIpAddress.substring(1) : brainIpAddress) + ":"
                + NeeoConstants.DEFAULT_BRAIN_PORT + NeeoConstants.GETLOG;

        final HttpRequest rqst = request.get();
        final HttpResponse res = rqst.sendGetCommand(logUrl);
        if (res.getHttpCode() != HttpStatus.OK_200) {
            throw res.createException();
        }

        final StringBuilder bld = new StringBuilder(1000);
        bld.append("<pre><div><span>");

        final char[] resp = res.getContent().toCharArray();
        for (int x = 0; x < resp.length; x++) {
            final char ch = resp[x];
            final char nx = x + 1 == resp.length ? '-' : resp[x + 1];

            if ((ch == '\n' || ch == '\r')) {
                if (x + 1 < resp.length) {
                    bld.append("</span></div><div><span>");
                }
            } else if (ch == 27 && nx == '[') {
                bld.append("</span>");
                x++;
                String codes = "";
                while (x + 1 < resp.length && resp[++x] != 'm') {
                    codes += resp[x];
                }

                String style = "";
                for (String code : codes.split(";")) {
                    try {
                        int cint = Integer.parseInt(code);
                        if (cint == 0) {
                            style = "";
                        } else if (cint == 1) {
                            style += "font-weight:bold;";
                        } else if (cint == 4) {
                            style += "font-style:italic;";
                        } else if (cint >= 30 && cint <= 37) {
                            style += "color:" + ANSICOLORS[cint - 30];
                        } else if (cint >= 40 && cint <= 47) {
                            style += "background-color:" + ANSICOLORS[cint - 40];
                        }
                    } catch (NumberFormatException e) {
                        // ignore
                    }
                }
                bld.append("<span style='" + style + "'>");
            } else {
                bld.append(ch);
            }
            if (x + 1 == resp.length) {
                bld.append("</span></div></pre>");
            }
        }
        return bld.toString();
    }

    /**
     * Start the API by scheduling the connection via {@link #scheduleConnect()}
     */
    public void start() {
        scheduleConnect(0);
    }

    /**
     * Restarts the API by closing and reopening the connection. Please note that this is currently an alias for
     * {@link #start()} (which does the same thing)
     */
    public void restart() {
        start();
    }

    /**
     * Checks to see if the API is currently connected or not
     *
     * @return true if connected, false otherwise
     */
    public boolean isConnected() {
        return connected.get();
    }

    /**
     * Schedules a connection attempt in 5 seconds. Simply calls {@link #scheduleConnect(int)} with 5 as it's parameter
     */
    private void scheduleConnect() {
        scheduleConnect(5);
    }

    /**
     * Schedules a connection attempt (will cancel any prior connection attempt if active)
     *
     * @param scheduledTimeInSeconds the time to wait (in seconds) before scheduling a connect
     */
    private void scheduleConnect(int scheduledTimeInSeconds) {
        NeeoUtil.cancel(connect.getAndSet(scheduler.schedule(() -> {
            connect();
        }, scheduledTimeInSeconds, TimeUnit.SECONDS)));
    }

    /**
     * Schedules a check status attempt (will cancel any prior connection attempt if active)l
     */
    private void scheduleCheckStatus() {
        NeeoUtil.cancel(checkStatus.getAndSet(scheduler.scheduleWithFixedDelay(() -> {
            checkStatus();
        }, checkStatusInterval, checkStatusInterval, TimeUnit.SECONDS)));
    }

    /**
     * Checks the status of the brain via a quick socket connection. If the status is unavailable, we schedule a
     * connection attempt via {@link #scheduleConnect()} and end this task
     */
    private void checkStatus() {
        try {
            try (Socket soc = new Socket()) {
                soc.connect(new InetSocketAddress(brainIpAddress, NeeoConstants.DEFAULT_BRAIN_PORT),
                        NeeoConstants.CONNECTION_TIMEOUT);
            }
            logger.debug("Checking connectivity to {}:{} - successful", brainIpAddress,
                    NeeoConstants.DEFAULT_BRAIN_PORT);
        } catch (IOException ex) {
            logger.debug("Checking connectivity to {}:{} - unsuccessful - going offline: {}", brainIpAddress,
                    NeeoConstants.DEFAULT_BRAIN_PORT, ex.getMessage(), ex);
            setConnected(false);
            NeeoUtil.cancel(checkStatus.getAndSet(null));
            scheduleConnect();
        }
    }

    /**
     * Attempts to connect to the brain by first registering our API ({@link #registerApi()} and then refreshes our
     * device keys found on the brain.
     * Any IOException will be caught and another attempt will be scheduled
     */
    private void connect() {
        logger.debug("Starting connect for {} at {}", brainId, brainIpAddress);
        try {
            setConnected(false);

            NeeoUtil.close(request.getAndSet(new HttpRequest()));

            NeeoUtil.checkInterrupt();
            registerApi();

            NeeoUtil.checkInterrupt();
            deviceKeys.refresh();

            NeeoUtil.checkInterrupt();
            setConnected(true);
            logger.debug("Connection successful for {} at {}", brainId, brainIpAddress);

            NeeoUtil.checkInterrupt();
            scheduleCheckStatus();
        } catch (IOException e) {
            logger.debug("Connection failed {} at {} (scheduling another connect): {} ", brainId, brainIpAddress,
                    e.getMessage(), e);
            scheduleConnect();
        } catch (InterruptedException e) {
            logger.debug("Connect was interrupted", e);
        }
    }

    /**
     * Register our API with the brain
     *
     * @throws IOException Signals that an I/O exception has occurred.
     */
    private void registerApi() throws IOException {
        deregisterApi();

        final HttpRequest rqst = request.get();
        logger.debug("Registering {} for {}{} using callback {}", brainId, brainUrl,
                NeeoConstants.REGISTER_SDK_ADAPTER, callbackUrl);
        final String register = GSON.toJson(new NeeoAdapterRegistration(
                NeeoConstants.ADAPTER_NAME + "-" + InstanceUUID.get(), callbackUrl.toExternalForm()));
        final HttpResponse resp = rqst.sendPostJsonCommand(brainUrl + NeeoConstants.REGISTER_SDK_ADAPTER, register);
        if (resp.getHttpCode() != HttpStatus.OK_200) {
            throw resp.createException();
        }
    }

    /**
     * Get's the brain identifier for this API
     *
     * @return the brain id
     */
    public String getBrainId() {
        return brainId;
    }

    /**
     * Gets the brain URL for this API
     *
     * @return the brain url
     */
    public String getBrainUrl() {
        return brainUrl;
    }

    /**
     * Gets the {@link NeeoDeviceKeys}
     *
     * @return the device keys
     */
    public NeeoDeviceKeys getDeviceKeys() {
        return deviceKeys;
    }

    /**
     * Send a notification to the brain
     *
     * @param msg the possibly null, possibly empty notification to send
     * @throws IOException Signals that an I/O exception has occurred.
     */
    public void notify(String msg) throws IOException {
        if (isConnected()) {
            final HttpRequest rqst = request.get();
            logger.debug("Sending Notification to brain ({}): {}", brainId, msg);
            final HttpResponse resp = rqst.sendPostJsonCommand(brainUrl + NeeoConstants.NOTIFICATION, msg);
            if (resp.getHttpCode() != HttpStatus.OK_200) {
                throw resp.createException();
            }
        } else {
            logger.debug("Notification ignored - brain not connected");
        }
    }

    /**
     * Deregister our API with the brain.
     *
     * @throws IOException Signals that an I/O exception has occurred.
     */
    private void deregisterApi() throws IOException {
        final HttpRequest rqst = request.get();
        try {
            logger.debug("Deregistering {} for {}{} using callback {}", brainId, brainUrl,
                    NeeoConstants.UNREGISTER_SDK_ADAPTER, callbackUrl);
            final String deregister = GSON.toJson(new NeeoAdapterRegistration(
                    NeeoConstants.ADAPTER_NAME + "-" + InstanceUUID.get(), callbackUrl.toExternalForm()));
            final HttpResponse resp = rqst.sendPostJsonCommand(brainUrl + NeeoConstants.UNREGISTER_SDK_ADAPTER,
                    deregister);
            if (resp.getHttpCode() != HttpStatus.OK_200) {
                throw resp.createException();
            }
        } finally {
            NeeoUtil.cancel(checkStatus.getAndSet(null));
        }
    }

    /**
     * Executes a recipe for the given deviceKey
     *
     * @param deviceKey the non-empty device key
     * @param on        true to start the recipe, false to stop
     * @throws IOException Signals that an I/O exception has occurred.
     */
    public void executeRecipe(String deviceKey, boolean on) throws IOException {
        NeeoUtil.requireNotEmpty(deviceKey, "deviceKey cannot be empty");

        final HttpRequest rqst = request.get();
        final HttpResponse resp = rqst.sendGetCommand(brainUrl + NeeoConstants.RECIPES);
        if (resp.getHttpCode() != HttpStatus.OK_200) {
            throw resp.createException();
        }

        for (NeeoRecipe recipe : GSON.fromJson(resp.getContent(), NeeoRecipe[].class)) {
            if (StringUtils.equalsIgnoreCase(recipe.getUid(), deviceKey)) {
                final NeeoRecipeUrls urls = recipe.getUrls();
                final String url = urls == null ? null : (on ? urls.getSetPowerOn() : urls.getSetPowerOff());

                if (url != null && StringUtils.isNotEmpty(url)) {
                    final HttpResponse cmdResp = rqst.sendGetCommand(url);
                    if (cmdResp.getHttpCode() != HttpStatus.OK_200) {
                        throw cmdResp.createException();
                    }
                }
                break;
            }
        }
    }

    /**
     * Returns the IP Address for the brain
     *
     * @return a non-null, non-empty, valid IP Address
     */
    public String getBrainIpAddress() {
        return brainIpAddress;
    }

    /**
     * Simply cancels any connection attempt and de-registers the API.
     */
    @Override
    public void close() {
        // kill our threads
        NeeoUtil.cancel(checkStatus.getAndSet(null));
        NeeoUtil.cancel(connect.getAndSet(null));

        try {
            deregisterApi();
        } catch (IOException e) {
            logger.debug("Exception while deregistring api during close - ignoring: {}", e.getMessage(), e);
        } finally {
            // Do this regardless if a runtime exception was thrown
            NeeoUtil.close(request.get());
            setConnected(false);
        }

    }

    /**
     * Adds a {@link PropertyChangeListener} for the given propertyChange
     *
     * @param propertyName a non-null, non-empty property name
     * @param listener     a non-null listener to add
     */
    public void addPropertyChangeListener(String propertyName, PropertyChangeListener listener) {
        NeeoUtil.requireNotEmpty(propertyName, "propertyName must not be empty");
        Objects.requireNonNull(listener, "listener cannot be null");
        propertySupport.addPropertyChangeListener(propertyName, listener);
    }

    /**
     * Removes the {@link PropertyChangeListener} from property change notifications
     *
     * @param listener a non-null listener to remove
     */
    public void removePropertyChangeListener(PropertyChangeListener listener) {
        Objects.requireNonNull(listener, "listener cannot be null");
        propertySupport.removePropertyChangeListener(listener);
    }

    /**
     * Helper method to set the connected status and fire off change listener
     *
     * @param connected true if connected, false otherwise
     */
    private void setConnected(boolean connected) {
        final boolean oldValue = this.connected.getAndSet(connected);

        if (oldValue != connected) {
            propertySupport.firePropertyChange(CONNECTED, oldValue, connected);
        }
    }
}