]> git.basschouten.com Git - openhab-addons.git/commitdiff
[neohub] Add support for WebSocket connection to hub (#12915)
authorAndrew Fiddian-Green <software@whitebear.ch>
Mon, 1 Aug 2022 19:58:39 +0000 (20:58 +0100)
committerGitHub <noreply@github.com>
Mon, 1 Aug 2022 19:58:39 +0000 (21:58 +0200)
* [neohub] add support for secure web socket connection
* [neohub] clean code
* [neohub] synchronize api calls
* [neohub] rename classes, fix compiler errors, remove SuppressWarnings

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
15 files changed:
bundles/org.openhab.binding.neohub/README.md
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubBindingConstants.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubConfiguration.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubDiscoveryService.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubException.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubHandler.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubReadDcbResponse.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocket.java
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocketBase.java [new file with mode: 0644]
bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubWebSocket.java [new file with mode: 0644]
bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/i18n/neohub.properties
bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubJsonTests.java [new file with mode: 0644]
bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubProtocolTests.java [new file with mode: 0644]
bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubTestData.java [deleted file]

index 9b0b64eb174f3cf86b5275035bbd7ea7ae73d861..ea8f85a5fea4bc4dcfceb2f571eb76b755537ca7 100644 (file)
@@ -33,21 +33,31 @@ It signs on to the hub using the supplied connection parameters, and it polls th
 The NeoHub supports two Application Programming Interfaces "API" (an older "legacy" one, and a modern one), and this binding can use either of them to communicate with it.
 Before the binding can communicate with the hub, the following Configuration Parameters must be entered.
 
-| Configuration Parameter | Description                                                                                 |
-|-------------------------|---------------------------------------------------------------------------------------------|
-| hostName                | Host name (IP address) of the NeoHub (example 192.168.1.123)                                |
-| portNumber              | Port number of the NeoHub (Default=4242)                                                    |
-| pollingInterval         | Time (seconds) between polling requests to the NeoHub (Min=4, Max=60, Default=60)           |
-| socketTimeout           | Time (seconds) to allow for TCP socket connections to the hub to succeed (Min=4, Max=20, Default=5) |
-| preferLegacyApi         | ADVANCED: Prefer the binding to use older API calls; if these are not supported, it switches to the new calls (Default=false) |
+| Configuration Parameter    | Description                                                                                              |
+|----------------------------|----------------------------------------------------------------------------------------------------------|
+| hostName                   | Host name (IP address) of the NeoHub (example 192.168.1.123)                                             |
+| useWebSocket<sup>1)</sup>  | Use secure WebSocket to connect to  the NeoHub (example `true`)                                          |
+| apiToken<sup>1)</sup>      | API Access Token for secure connection to hub. Create the token in the Heatmiser mobile App              |
+| pollingInterval            | Time (seconds) between polling requests to the NeoHub (Min=4, Max=60, Default=60)                        |
+| socketTimeout              | Time (seconds) to allow for TCP socket connections to the hub to succeed (Min=4, Max=20, Default=5)      |
+| preferLegacyApi            | ADVANCED: Prefer to use older API calls; but if not supported, it switches to new calls (Default=false)  |
+| portNumber<sup>2)</sup>    | ADVANCED: Port number for connection to the NeoHub (Default=0 (automatic))                               |
+
+<sup>1)</sup> If `useWebSocket` is false, the binding will connect via an older and less secure TCP connection, in which case `apiToken` is not required.
+However see the chapter "Connection Refused Errors" below.
+Whereas if you prefer to connect via more secure WebSocket connections then an API access token `apiToken` is required.
+You can create an API access token in the Heatmiser mobile App (Settings | System | API Access).
+
+<sup>2)</sup> Normally the port number is chosen automatically (for TCP it is 4242 and for WebSocket it is 4243).
+But you can override this in special cases if you want to use (say) port forwarding.
 
 ## Connection Refused Errors
 
-From early 2022 Heatmiser introduced NeoHub firmware that has the ability to enable / disable the NeoHub `portNumber` 4242.
-If this port is disabled the OpenHAB binding cannot connect and the binding will report a *"Connection Refused"* warning in the log.
-In prior firmware versions the port was always enabled.
-But in the new firmware the port is initially enabled on power up but if no communication occurs for 48 hours it is automatically disabled.
-Alternatively the Heatmiser mobile App has a setting (Settings | System | API Access | Legacy API Enable | On) whereby the port can be permanently enabled.
+From early 2022 Heatmiser introduced NeoHub firmware that has the ability to enable / disable connecting to it via a TCP port.
+If the TCP port is disabled the OpenHAB binding cannot connect and the binding will report a *"Connection Refused"* warning in the log.
+In prior firmware versions the TCP port was always enabled.
+But in the new firmware the TCP port is initially enabled on power up but if no communication occurs for 48 hours it is automatically disabled.
+Alternatively the Heatmiser mobile app has a setting (Settings | System | API Access | Legacy API Enable | On) whereby the TCP port can be permanently enabled.
 
 ## Thing Configuration for "NeoStat" and "NeoPlug"
 
index 710328cf325b119a2e58cc635c40aedc7a5a62f8..e4f950037d82df301fa0fbfa08e407221324674b 100644 (file)
@@ -196,4 +196,16 @@ public class NeoHubBindingConstants {
     public static final String PROPERTY_FIRMWARE_VERSION = "Firmware version";
     public static final String PROPERTY_API_VERSION = "API version";
     public static final String PROPERTY_API_DEVICEINFO = "Devices [online/total]";
+
+    /*
+     * reserved ports on the hub
+     */
+    public static final int PORT_TCP = 4242;
+    public static final int PORT_WSS = 4243;
+
+    /*
+     * web socket communication constants
+     */
+    public static final String HM_GET_COMMAND_QUEUE = "hm_get_command_queue";
+    public static final String HM_SET_COMMAND_RESPONSE = "hm_set_command_response";
 }
index 88977d1281afb0ce0adc34ba95002176102d856e..afebbce9112f2768cd2b37150277afe25839d281 100644 (file)
@@ -30,4 +30,6 @@ public class NeoHubConfiguration {
     public int pollingInterval;
     public int socketTimeout;
     public boolean preferLegacyApi;
+    public String apiToken = "";
+    public boolean useWebSocket;
 }
index ca6e2f28ff3d52f2faaf844b6d69e02a7de0c0b5..cff7cf137953eb29425887ccdafdfc23ff7a45b0 100644 (file)
@@ -40,7 +40,7 @@ import org.slf4j.LoggerFactory;
  * Discovery service for neo devices
  *
  * @author Andrew Fiddian-Green - Initial contribution
- * 
+ *
  */
 @NonNullByDefault
 public class NeoHubDiscoveryService extends AbstractDiscoveryService {
@@ -113,11 +113,11 @@ public class NeoHubDiscoveryService extends AbstractDiscoveryService {
                     // the record came from the legacy API (deviceType included)
                     if (deviceRecord instanceof InfoRecord) {
                         deviceType = ((InfoRecord) deviceRecord).getDeviceType();
-                        publishDevice((InfoRecord) deviceRecord, deviceType);
+                        publishDevice(deviceRecord, deviceType);
                         continue;
                     }
 
-                    // the record came from the now API (deviceType NOT included)
+                    // the record came from the new API (deviceType NOT included)
                     if (deviceRecord instanceof LiveDataRecord) {
                         if (engineerData == null) {
                             break;
@@ -128,7 +128,7 @@ public class NeoHubDiscoveryService extends AbstractDiscoveryService {
                             continue;
                         }
                         deviceType = engineerData.getDeviceType(deviceName);
-                        publishDevice((LiveDataRecord) deviceRecord, deviceType);
+                        publishDevice(deviceRecord, deviceType);
                     }
                 }
             }
index 1541dac1333a778bf20b4fac7073199f71035ba2..55c4bd2a243b0b1f653940f296cd588d7090750f 100644 (file)
@@ -18,7 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
  * The {@link NeoHubException} is a custom exception for NeoHub
  *
  * @author Andrew Fiddian-Green - Initial contribution
- * 
+ *
  */
 @NonNullByDefault
 public class NeoHubException extends Exception {
@@ -28,4 +28,8 @@ public class NeoHubException extends Exception {
     public NeoHubException(String message) {
         super(message);
     }
+
+    public NeoHubException(String message, Throwable cause) {
+        super(message, cause);
+    }
 }
index be17626a9a7199027a6c1d86d1fba870a2127802..15bb98ce1a745b8a129e4433694be9077e10e890 100644 (file)
@@ -65,7 +65,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
     private final Map<String, Boolean> connectionStates = new HashMap<>();
 
     private @Nullable NeoHubConfiguration config;
-    private @Nullable NeoHubSocket socket;
+    private @Nullable NeoHubSocketBase socket;
     private @Nullable ScheduledFuture<?> lazyPollingScheduler;
     private @Nullable ScheduledFuture<?> fastPollingScheduler;
 
@@ -113,7 +113,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
             logger.debug("hub '{}' port={}", getThing().getUID(), config.portNumber);
         }
 
-        if (config.portNumber <= 0 || config.portNumber > 0xFFFF) {
+        if (config.portNumber < 0 || config.portNumber > 0xFFFF) {
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "portNumber is invalid!");
             return;
         }
@@ -142,7 +142,20 @@ public class NeoHubHandler extends BaseBridgeHandler {
             logger.debug("hub '{}' preferLegacyApi={}", getThing().getUID(), config.preferLegacyApi);
         }
 
-        NeoHubSocket socket = this.socket = new NeoHubSocket(config.hostName, config.portNumber, config.socketTimeout);
+        // create a web or TCP socket based on the port number in the configuration
+        NeoHubSocketBase socket;
+        try {
+            if (config.useWebSocket) {
+                socket = new NeoHubWebSocket(config);
+            } else {
+                socket = new NeoHubSocket(config);
+            }
+        } catch (NeoHubException e) {
+            logger.debug("\"hub '{}' error creating web/tcp socket: '{}'", getThing().getUID(), e.getMessage());
+            return;
+        }
+
+        this.socket = socket;
         this.config = config;
 
         /*
@@ -206,6 +219,15 @@ public class NeoHubHandler extends BaseBridgeHandler {
             fast.cancel(true);
             this.fastPollingScheduler = null;
         }
+
+        NeoHubSocketBase socket = this.socket;
+        if (socket != null) {
+            try {
+                socket.close();
+            } catch (IOException e) {
+            }
+            this.socket = null;
+        }
     }
 
     /*
@@ -220,7 +242,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
      * device handlers call this method to issue commands to the NeoHub
      */
     public synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandStr) {
-        NeoHubSocket socket = this.socket;
+        NeoHubSocketBase socket = this.socket;
 
         if (socket == null || config == null) {
             return NeoHubReturnResult.ERR_INITIALIZATION;
@@ -246,7 +268,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
      * @return a class that contains the full status of all devices
      */
     protected @Nullable NeoHubAbstractDeviceData fromNeoHubGetDeviceData() {
-        NeoHubSocket socket = this.socket;
+        NeoHubSocketBase socket = this.socket;
 
         if (socket == null || config == null) {
             logger.warn(MSG_HUB_CONFIG, getThing().getUID());
@@ -322,7 +344,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
      * @return a class that contains the status of the system
      */
     protected @Nullable NeoHubReadDcbResponse fromNeoHubReadSystemData() {
-        NeoHubSocket socket = this.socket;
+        NeoHubSocketBase socket = this.socket;
 
         if (socket == null) {
             return null;
@@ -443,7 +465,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
         boolean supportsLegacyApi = false;
         boolean supportsFutureApi = false;
 
-        NeoHubSocket socket = this.socket;
+        NeoHubSocketBase socket = this.socket;
         if (socket != null) {
             String responseJson;
             NeoHubReadDcbResponse systemData;
@@ -498,7 +520,7 @@ public class NeoHubHandler extends BaseBridgeHandler {
      * get the Engineers data
      */
     public @Nullable NeoHubGetEngineersData fromNeoHubGetEngineersData() {
-        NeoHubSocket socket = this.socket;
+        NeoHubSocketBase socket = this.socket;
         if (socket != null) {
             String responseJson;
             try {
index 05d4f8f6b22c35d1bddfaa56eb01ad6f90d2dd33..00e0cb7adbf7b4f3539a8d39fa9c267e767a7576 100644 (file)
@@ -59,9 +59,11 @@ public class NeoHubReadDcbResponse {
     }
 
     public @Nullable String getFirmwareVersion() {
+        BigDecimal firmwareVersionNew = this.firmwareVersionNew;
         if (firmwareVersionNew != null) {
             return firmwareVersionNew.toString();
         }
+        BigDecimal firmwareVersionOld = this.firmwareVersionOld;
         if (firmwareVersionOld != null) {
             return firmwareVersionOld.toString();
         }
index 7d289ef644125e4d03647c8246c415d2a1d5eaee..301fefb8e1755241b991933a84907ee1231b3584 100644 (file)
@@ -25,54 +25,30 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /**
- * NeoHubConnector handles the ASCII based communication via TCP between openHAB
- * and NeoHub
+ * Handles the ASCII based communication via TCP socket between openHAB and NeoHub
  *
  * @author Sebastian Prehn - Initial contribution
  * @author Andrew Fiddian-Green - Refactoring for openHAB v2.x
  *
  */
 @NonNullByDefault
-public class NeoHubSocket {
+public class NeoHubSocket extends NeoHubSocketBase {
 
     private final Logger logger = LoggerFactory.getLogger(NeoHubSocket.class);
 
-    /**
-     * Name of host or IP to connect to.
-     */
-    private final String hostname;
-
-    /**
-     * The port to connect to
-     */
-    private final int port;
-
-    /**
-     * The socket connect resp. read timeout value
-     */
-    private final int timeout;
-
-    public NeoHubSocket(final String hostname, final int portNumber, final int timeoutSeconds) {
-        this.hostname = hostname;
-        this.port = portNumber;
-        this.timeout = timeoutSeconds * 1000;
+    public NeoHubSocket(NeoHubConfiguration config) {
+        super(config);
     }
 
-    /**
-     * sends the message over the network to the NeoHub and returns its response
-     *
-     * @param requestJson the message to be sent to the NeoHub
-     * @return responseJson received from NeoHub
-     * @throws NeoHubException, IOException
-     *
-     */
-    public String sendMessage(final String requestJson) throws IOException, NeoHubException {
+    @Override
+    public synchronized String sendMessage(final String requestJson) throws IOException, NeoHubException {
         IOException caughtException = null;
         StringBuilder builder = new StringBuilder();
 
         try (Socket socket = new Socket()) {
-            socket.connect(new InetSocketAddress(hostname, port), timeout);
-            socket.setSoTimeout(timeout);
+            int port = config.portNumber > 0 ? config.portNumber : NeoHubBindingConstants.PORT_TCP;
+            socket.connect(new InetSocketAddress(config.hostName, port), config.socketTimeout * 1000);
+            socket.setSoTimeout(config.socketTimeout * 1000);
 
             try (InputStreamReader reader = new InputStreamReader(socket.getInputStream(), US_ASCII);
                     OutputStreamWriter writer = new OutputStreamWriter(socket.getOutputStream(), US_ASCII)) {
@@ -128,4 +104,9 @@ public class NeoHubSocket {
 
         return responseJson;
     }
+
+    @Override
+    public void close() {
+        // nothing to do
+    }
 }
diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocketBase.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocketBase.java
new file mode 100644 (file)
index 0000000..3ee2b96
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010-2022 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.binding.neohub.internal;
+
+import java.io.Closeable;
+import java.io.IOException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Base abstract class for ASCII based communication between openHAB and NeoHub
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ *
+ */
+@NonNullByDefault
+public abstract class NeoHubSocketBase implements Closeable {
+
+    protected final NeoHubConfiguration config;
+
+    public NeoHubSocketBase(NeoHubConfiguration config) {
+        this.config = config;
+    }
+
+    /**
+     * Sends the message over the network to the NeoHub and returns its response
+     *
+     * @param requestJson the message to be sent to the NeoHub
+     * @return responseJson received from NeoHub
+     * @throws NeoHubException, IOException
+     *
+     */
+    public abstract String sendMessage(final String requestJson) throws IOException, NeoHubException;
+}
diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubWebSocket.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubWebSocket.java
new file mode 100644 (file)
index 0000000..f62dc86
--- /dev/null
@@ -0,0 +1,238 @@
+/**
+ * Copyright (c) 2010-2022 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.binding.neohub.internal;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.concurrent.ExecutionException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.eclipse.jetty.websocket.client.WebSocketClient;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * Handles the ASCII based communication via web socket between openHAB and NeoHub
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ *
+ */
+@NonNullByDefault
+@WebSocket
+public class NeoHubWebSocket extends NeoHubSocketBase {
+
+    private static final int SLEEP_MILLISECONDS = 100;
+    private static final String REQUEST_OUTER = "{\"message_type\":\"hm_get_command_queue\",\"message\":\"%s\"}";
+    private static final String REQUEST_INNER = "{\"token\":\"%s\",\"COMMANDS\":[{\"COMMAND\":\"%s\",\"COMMANDID\":1}]}";
+
+    private final Logger logger = LoggerFactory.getLogger(NeoHubWebSocket.class);
+    private final Gson gson = new Gson();
+    private final WebSocketClient webSocketClient;
+
+    private @Nullable Session session = null;
+    private String responseOuter = "";
+    private boolean responseWaiting;
+
+    /**
+     * DTO to receive and parse the response JSON.
+     *
+     * @author Andrew Fiddian-Green - Initial contribution
+     */
+    private static class Response {
+        @SuppressWarnings("unused")
+        public @Nullable String command_id;
+        @SuppressWarnings("unused")
+        public @Nullable String device_id;
+        public @Nullable String message_type;
+        public @Nullable String response;
+    }
+
+    public NeoHubWebSocket(NeoHubConfiguration config) throws NeoHubException {
+        super(config);
+
+        // initialise and start ssl context factory, http client, web socket client
+        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
+        sslContextFactory.setTrustAll(true);
+        HttpClient httpClient = new HttpClient(sslContextFactory);
+        try {
+            httpClient.start();
+        } catch (Exception e) {
+            throw new NeoHubException(String.format("Error starting http client: '%s'", e.getMessage()));
+        }
+        webSocketClient = new WebSocketClient(httpClient);
+        webSocketClient.setConnectTimeout(config.socketTimeout * 1000);
+        try {
+            webSocketClient.start();
+        } catch (Exception e) {
+            throw new NeoHubException(String.format("Error starting web socket client: '%s'", e.getMessage()));
+        }
+    }
+
+    /**
+     * Open the web socket session.
+     *
+     * @throws NeoHubException
+     */
+    private void startSession() throws NeoHubException {
+        Session session = this.session;
+        if (session == null || !session.isOpen()) {
+            closeSession();
+            try {
+                int port = config.portNumber > 0 ? config.portNumber : NeoHubBindingConstants.PORT_WSS;
+                URI uri = new URI(String.format("wss://%s:%d", config.hostName, port));
+                webSocketClient.connect(this, uri).get();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new NeoHubException(String.format("Error starting session: '%s'", e.getMessage(), e));
+            } catch (ExecutionException | IOException | URISyntaxException e) {
+                throw new NeoHubException(String.format("Error starting session: '%s'", e.getMessage(), e));
+            }
+        }
+    }
+
+    /**
+     * Close the web socket session.
+     */
+    private void closeSession() {
+        Session session = this.session;
+        if (session != null) {
+            session.close();
+            this.session = null;
+        }
+    }
+
+    /**
+     * Helper to escape the quote marks in a JSON string.
+     *
+     * @param json the input JSON string.
+     * @return the escaped JSON version.
+     */
+    private String jsonEscape(String json) {
+        return json.replace("\"", "\\\"");
+    }
+
+    /**
+     * Helper to remove quote escape marks from an escaped JSON string.
+     *
+     * @param escapedJson the escaped input string.
+     * @return the clean JSON version.
+     */
+    private String jsonUnEscape(String escapedJson) {
+        return escapedJson.replace("\\\"", "\"");
+    }
+
+    /**
+     * Helper to replace double quote marks in a JSON string with single quote marks.
+     *
+     * @param json the input string.
+     * @return the modified version.
+     */
+    private String jsonReplaceQuotes(String json) {
+        return json.replace("\"", "'");
+    }
+
+    @Override
+    public synchronized String sendMessage(final String requestJson) throws IOException, NeoHubException {
+        // start the session
+        startSession();
+
+        // session start failed
+        Session session = this.session;
+        if (session == null) {
+            throw new NeoHubException("Session is null.");
+        }
+
+        // wrap the inner request in an outer request string
+        String requestOuter = String.format(REQUEST_OUTER,
+                jsonEscape(String.format(REQUEST_INNER, config.apiToken, jsonReplaceQuotes(requestJson))));
+
+        // initialise the response
+        responseOuter = "";
+        responseWaiting = true;
+
+        // send the request
+        logger.trace("Sending request: {}", requestOuter);
+        session.getRemote().sendString(requestOuter);
+
+        // sleep and loop until we get a response or the socket is closed
+        int sleepRemainingMilliseconds = config.socketTimeout * 1000;
+        while (responseWaiting && (sleepRemainingMilliseconds > 0)) {
+            try {
+                Thread.sleep(SLEEP_MILLISECONDS);
+                sleepRemainingMilliseconds = sleepRemainingMilliseconds - SLEEP_MILLISECONDS;
+            } catch (InterruptedException e) {
+                throw new NeoHubException(String.format("Read timeout '%s'", e.getMessage()));
+            }
+        }
+
+        // extract the inner response from the outer response string
+        Response responseDto = gson.fromJson(responseOuter, Response.class);
+        if (responseDto != null && NeoHubBindingConstants.HM_SET_COMMAND_RESPONSE.equals(responseDto.message_type)) {
+            String responseJson = responseDto.response;
+            if (responseJson != null) {
+                responseJson = jsonUnEscape(responseJson);
+                logger.trace("Received response: {}", responseJson);
+                return responseJson;
+            }
+        }
+        logger.debug("Null or invalid response.");
+        return "";
+    }
+
+    @Override
+    public void close() {
+        closeSession();
+        try {
+            webSocketClient.stop();
+        } catch (Exception e) {
+        }
+    }
+
+    @OnWebSocketConnect
+    public void onConnect(Session session) {
+        logger.trace("onConnect: ok");
+        this.session = session;
+    }
+
+    @OnWebSocketClose
+    public void onClose(int statusCode, String reason) {
+        logger.trace("onClose: code:{}, reason:{}", statusCode, reason);
+        responseWaiting = false;
+        this.session = null;
+    }
+
+    @OnWebSocketError
+    public void onError(Throwable cause) {
+        logger.trace("onError: cause:{}", cause.getMessage());
+        closeSession();
+    }
+
+    @OnWebSocketMessage
+    public void onMessage(String msg) {
+        logger.trace("onMessage: msg:{}", msg);
+        responseOuter = msg;
+        responseWaiting = false;
+    }
+}
index dddda2cf9d342804620e6601b704797aabd2ce69..d29d0252187028b7bde8aa45b1834b68c593559e 100644 (file)
@@ -35,11 +35,15 @@ thing-type.config.neohub.neohub.hostName.description = Host name (IP address) of
 thing-type.config.neohub.neohub.pollingInterval.label = Polling Interval
 thing-type.config.neohub.neohub.pollingInterval.description = Time (seconds) between polling the NeoHub (min=4, max/default=60)
 thing-type.config.neohub.neohub.portNumber.label = Port Number
-thing-type.config.neohub.neohub.portNumber.description = Port number of the NeoHub
+thing-type.config.neohub.neohub.portNumber.description = Override port number to use to connect to the NeoHub (0=automatic)
 thing-type.config.neohub.neohub.preferLegacyApi.label = Prefer Legacy API
 thing-type.config.neohub.neohub.preferLegacyApi.description = Use the legacy API instead of the new API (if available)
 thing-type.config.neohub.neohub.socketTimeout.label = Socket Timeout
 thing-type.config.neohub.neohub.socketTimeout.description = Time (seconds) to wait for connections to the Hub (min/default=5, max=20)
+thing-type.config.neohub.neohub.apiToken.label = API Access Token
+thing-type.config.neohub.neohub.apiToken.description = API access token for the hub (created on the Heatmiser mobile App)
+thing-type.config.neohub.neohub.useWebSocket.label = Connect via WebSocket
+thing-type.config.neohub.neohub.useWebSocket.description = Select whether to communicate with the Neohub via WebSocket or TCP
 thing-type.config.neohub.neoplug.deviceNameInHub.label = Device Name
 thing-type.config.neohub.neoplug.deviceNameInHub.description = Device Name that identifies the NeoPlug device in the NeoHub and Heatmiser App
 thing-type.config.neohub.neostat.deviceNameInHub.label = Device Name
index 1aa673651a8bcdf51a89f87331eb25775ad27b21..75e9aef9009262a4cfc782a9371a45acc98dce0a 100644 (file)
@@ -28,8 +28,8 @@
 
                        <parameter name="portNumber" type="integer" required="false">
                                <label>Port Number</label>
-                               <description>Port number of the NeoHub</description>
-                               <default>4242</default>
+                               <description>Override port number to use to connect to the NeoHub (0=automatic)</description>
+                               <default>0</default>
                                <advanced>true</advanced>
                        </parameter>
 
                                <default>false</default>
                                <advanced>true</advanced>
                        </parameter>
+
+                       <parameter name="useWebSocket" type="boolean" required="false">
+                               <label>Connect via WebSocket</label>
+                               <description>Select whether to communicate with the Neohub via WebSocket or TCP</description>
+                               <default>false</default>
+                       </parameter>
+
+                       <parameter name="apiToken" type="text" required="false">
+                               <label>API Access Token</label>
+                               <description>API access token for the hub (created with the Heatmiser mobile app)</description>
+                       </parameter>
                </config-description>
 
        </bridge-type>
diff --git a/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubJsonTests.java b/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubJsonTests.java
new file mode 100644 (file)
index 0000000..0417e15
--- /dev/null
@@ -0,0 +1,419 @@
+/**
+ * Copyright (c) 2010-2022 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.binding.neohub.test;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.openhab.binding.neohub.internal.NeoHubBindingConstants.*;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.neohub.internal.NeoHubAbstractDeviceData;
+import org.openhab.binding.neohub.internal.NeoHubAbstractDeviceData.AbstractRecord;
+import org.openhab.binding.neohub.internal.NeoHubConfiguration;
+import org.openhab.binding.neohub.internal.NeoHubGetEngineersData;
+import org.openhab.binding.neohub.internal.NeoHubInfoResponse;
+import org.openhab.binding.neohub.internal.NeoHubInfoResponse.InfoRecord;
+import org.openhab.binding.neohub.internal.NeoHubLiveDeviceData;
+import org.openhab.binding.neohub.internal.NeoHubReadDcbResponse;
+import org.openhab.binding.neohub.internal.NeoHubSocket;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.SIUnits;
+
+/**
+ * JUnit for testing JSON parsing.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class NeoHubJsonTests {
+
+    /*
+     * to actually run tests on a physical device you must have a hub physically available, and its IP address must be
+     * correctly configured in the "hubIPAddress" string constant e.g. "192.168.1.123"
+     * note: only run the test if such a device is actually available
+     */
+    private static final String HUB_IP_ADDRESS = "192.168.1.xxx";
+
+    public static final Pattern VALID_IP_V4_ADDRESS = Pattern
+            .compile("\\b((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}\\b");
+
+    /**
+     * Load the test JSON payload string from a file
+     */
+    private String load(String fileName) {
+        try (FileReader file = new FileReader(String.format("src/test/resources/%s.json", fileName));
+                BufferedReader reader = new BufferedReader(file)) {
+            StringBuilder builder = new StringBuilder();
+            String line;
+            while ((line = reader.readLine()) != null) {
+                builder.append(line).append("\n");
+            }
+            return builder.toString();
+        } catch (IOException e) {
+            fail(e.getMessage());
+        }
+        return "";
+    }
+
+    /**
+     * Test an INFO JSON response string as produced by older firmware versions
+     */
+    @Test
+    public void testInfoJsonOld() {
+        // load INFO JSON response string in old JSON format
+        NeoHubAbstractDeviceData infoResponse = NeoHubInfoResponse.createDeviceData(load("info_old"));
+        assertNotNull(infoResponse);
+
+        // missing device
+        AbstractRecord device = infoResponse.getDeviceRecord("Aardvark");
+        assertNull(device);
+
+        // existing type 12 thermostat device
+        device = infoResponse.getDeviceRecord("Dining Room");
+        assertNotNull(device);
+        assertEquals("Dining Room", device.getDeviceName());
+        assertEquals(new BigDecimal("22.0"), device.getTargetTemperature());
+        assertEquals(new BigDecimal("22.2"), device.getActualTemperature());
+        assertEquals(new BigDecimal("23"), device.getFloorTemperature());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(12, ((InfoRecord) device).getDeviceType());
+        assertFalse(device.isStandby());
+        assertFalse(device.isHeating());
+        assertFalse(device.isPreHeating());
+        assertFalse(device.isTimerOn());
+        assertFalse(device.offline());
+        assertFalse(device.stateManual());
+        assertTrue(device.stateAuto());
+        assertFalse(device.isWindowOpen());
+        assertFalse(device.isBatteryLow());
+
+        // existing type 6 plug device (MANUAL OFF)
+        device = infoResponse.getDeviceRecord("Plug South");
+        assertNotNull(device);
+        assertEquals("Plug South", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(6, ((InfoRecord) device).getDeviceType());
+        assertFalse(device.isTimerOn());
+        assertTrue(device.stateManual());
+
+        // existing type 6 plug device (MANUAL ON)
+        device = infoResponse.getDeviceRecord("Plug North");
+        assertNotNull(device);
+        assertEquals("Plug North", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(6, ((InfoRecord) device).getDeviceType());
+        assertTrue(device.isTimerOn());
+        assertTrue(device.stateManual());
+
+        // existing type 6 plug device (AUTO OFF)
+        device = infoResponse.getDeviceRecord("Watering System");
+        assertNotNull(device);
+        assertEquals("Watering System", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(6, ((InfoRecord) device).getDeviceType());
+        assertFalse(device.isTimerOn());
+        assertFalse(device.stateManual());
+    }
+
+    /**
+     * Test an INFO JSON response string as produced by newer firmware versions
+     */
+    @Test
+    public void testInfoJsonNew() {
+        // load INFO JSON response string in new JSON format
+        NeoHubAbstractDeviceData infoResponse = NeoHubInfoResponse.createDeviceData(load("info_new"));
+        assertNotNull(infoResponse);
+
+        // existing device (new JSON format)
+        AbstractRecord device = infoResponse.getDeviceRecord("Dining Room");
+        assertNotNull(device);
+        assertEquals("Dining Room", device.getDeviceName());
+        assertFalse(device.offline());
+        assertFalse(device.isWindowOpen());
+
+        // existing repeater device
+        device = infoResponse.getDeviceRecord("repeaternode54473");
+        assertNotNull(device);
+        assertEquals("repeaternode54473", device.getDeviceName());
+        assertEquals(new BigDecimal("127"), device.getFloorTemperature());
+        assertEquals(new BigDecimal("255.255"), device.getActualTemperature());
+    }
+
+    /**
+     * Test for a READ_DCB JSON string that has valid CORF C response
+     */
+    @Test
+    public void testReadDcbJson() {
+        // load READ_DCB JSON response string with valid CORF C response
+        NeoHubReadDcbResponse dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_celsius"));
+        assertNotNull(dcbResponse);
+        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
+        assertEquals("2134", dcbResponse.getFirmwareVersion());
+
+        // load READ_DCB JSON response string with valid CORF F response
+        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_fahrenheit"));
+        assertNotNull(dcbResponse);
+        assertEquals(ImperialUnits.FAHRENHEIT, dcbResponse.getTemperatureUnit());
+
+        // load READ_DCB JSON response string with missing CORF element
+        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_corf_missing"));
+        assertNotNull(dcbResponse);
+        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
+
+        // load READ_DCB JSON response string where CORF element is an empty string
+        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_corf_empty"));
+        assertNotNull(dcbResponse);
+        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
+    }
+
+    /**
+     * Test an INFO JSON string that has a door contact and a temperature sensor
+     */
+    @Test
+    public void testInfoJsonWithSensors() {
+        /*
+         * load an INFO JSON response string that has a closed door contact and a
+         * temperature sensor
+         */
+        // save("info_sensors_closed", NEOHUB_JSON_TEST_STRING_INFO_SENSORS_CLOSED);
+        NeoHubAbstractDeviceData infoResponse = NeoHubInfoResponse.createDeviceData(load("info_sensors_closed"));
+        assertNotNull(infoResponse);
+
+        // existing contact device type 5 (CLOSED)
+        AbstractRecord device = infoResponse.getDeviceRecord("Back Door");
+        assertNotNull(device);
+        assertEquals("Back Door", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(5, ((InfoRecord) device).getDeviceType());
+        assertFalse(device.isWindowOpen());
+        assertFalse(device.isBatteryLow());
+
+        // existing temperature sensor type 14
+        device = infoResponse.getDeviceRecord("Master Bedroom");
+        assertNotNull(device);
+        assertEquals("Master Bedroom", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(14, ((InfoRecord) device).getDeviceType());
+        assertEquals(new BigDecimal("19.5"), device.getActualTemperature());
+
+        // existing thermostat type 1
+        device = infoResponse.getDeviceRecord("Living Room Floor");
+        assertNotNull(device);
+        assertEquals("Living Room Floor", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(1, ((InfoRecord) device).getDeviceType());
+        assertEquals(new BigDecimal("19.8"), device.getActualTemperature());
+
+        // load an INFO JSON response string that has an open door contact
+        // save("info_sensors_open", NEOHUB_JSON_TEST_STRING_INFO_SENSORS_OPEN);
+        infoResponse = NeoHubInfoResponse.createDeviceData(load("info_sensors_open"));
+        assertNotNull(infoResponse);
+
+        // existing contact device type 5 (OPEN)
+        device = infoResponse.getDeviceRecord("Back Door");
+        assertNotNull(device);
+        assertEquals("Back Door", device.getDeviceName());
+        assertTrue(device instanceof InfoRecord);
+        assertEquals(5, ((InfoRecord) device).getDeviceType());
+        assertTrue(device.isWindowOpen());
+        assertTrue(device.isBatteryLow());
+    }
+
+    /**
+     * From NeoHub rev2.6 onwards the READ_DCB command is "deprecated" so we can
+     * also test the replacement GET_SYSTEM command (valid CORF response)
+     */
+    @Test
+    public void testGetSystemJson() {
+        // load GET_SYSTEM JSON response string
+        NeoHubReadDcbResponse dcbResponse;
+        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("system"));
+        assertNotNull(dcbResponse);
+        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
+        assertEquals("2134", dcbResponse.getFirmwareVersion());
+    }
+
+    /**
+     * From NeoHub rev2.6 onwards the INFO command is "deprecated" so we must test
+     * the replacement GET_LIVE_DATA command
+     */
+    @Test
+    public void testGetLiveDataJson() {
+        // load GET_LIVE_DATA JSON response string
+        NeoHubLiveDeviceData liveDataResponse = NeoHubLiveDeviceData.createDeviceData(load("live_data"));
+        assertNotNull(liveDataResponse);
+
+        // test the time stamps
+        assertEquals(1588494785, liveDataResponse.getTimestampEngineers());
+        assertEquals(0, liveDataResponse.getTimestampSystem());
+
+        // missing device
+        AbstractRecord device = liveDataResponse.getDeviceRecord("Aardvark");
+        assertNull(device);
+
+        // test an existing thermostat device
+        device = liveDataResponse.getDeviceRecord("Dining Room");
+        assertNotNull(device);
+        assertEquals("Dining Room", device.getDeviceName());
+        assertEquals(new BigDecimal("22.0"), device.getTargetTemperature());
+        assertEquals(new BigDecimal("22.2"), device.getActualTemperature());
+        assertEquals(new BigDecimal("20.50"), device.getFloorTemperature());
+        assertFalse(device.isStandby());
+        assertFalse(device.isHeating());
+        assertFalse(device.isPreHeating());
+        assertFalse(device.isTimerOn());
+        assertFalse(device.offline());
+        assertFalse(device.stateManual());
+        assertTrue(device.stateAuto());
+        assertFalse(device.isWindowOpen());
+        assertFalse(device.isBatteryLow());
+
+        // test a plug device (MANUAL OFF)
+        device = liveDataResponse.getDeviceRecord("Living Room South");
+        assertNotNull(device);
+        assertEquals("Living Room South", device.getDeviceName());
+        assertFalse(device.isTimerOn());
+        assertTrue(device.stateManual());
+
+        // test a plug device (MANUAL ON)
+        device = liveDataResponse.getDeviceRecord("Living Room North");
+        assertNotNull(device);
+        assertEquals("Living Room North", device.getDeviceName());
+        assertTrue(device.isTimerOn());
+        assertTrue(device.stateManual());
+
+        // test a plug device (AUTO OFF)
+        device = liveDataResponse.getDeviceRecord("Green Wall Watering");
+        assertNotNull(device);
+        assertEquals("Green Wall Watering", device.getDeviceName());
+        assertFalse(device.isTimerOn());
+        assertFalse(device.stateManual());
+
+        // test a device that is offline
+        device = liveDataResponse.getDeviceRecord("Shower Room");
+        assertNotNull(device);
+        assertEquals("Shower Room", device.getDeviceName());
+        assertTrue(device.offline());
+
+        // test a device with a low battery
+        device = liveDataResponse.getDeviceRecord("Conservatory");
+        assertNotNull(device);
+        assertEquals("Conservatory", device.getDeviceName());
+        assertTrue(device.isBatteryLow());
+
+        // test a device with an open window alarm
+        device = liveDataResponse.getDeviceRecord("Door Contact");
+        assertNotNull(device);
+        assertEquals("Door Contact", device.getDeviceName());
+        assertTrue(device.isWindowOpen());
+
+        // test a wireless temperature sensor
+        device = liveDataResponse.getDeviceRecord("Room Sensor");
+        assertNotNull(device);
+        assertEquals("Room Sensor", device.getDeviceName());
+        assertEquals(new BigDecimal("21.5"), device.getActualTemperature());
+
+        // test a repeater node
+        device = liveDataResponse.getDeviceRecord("repeaternode54473");
+        assertNotNull(device);
+        assertEquals("repeaternode54473", device.getDeviceName());
+        assertTrue(MATCHER_HEATMISER_REPEATER.matcher(device.getDeviceName()).matches());
+    }
+
+    /**
+     * From NeoHub rev2.6 onwards the INFO command is "deprecated" and the DEVICE_ID
+     * element is not returned in the GET_LIVE_DATA call so we must test the
+     * replacement GET_ENGINEERS command
+     */
+    @Test
+    public void testGetEngineersJson() {
+        // load GET_ENGINEERS JSON response string
+        NeoHubGetEngineersData engResponse = NeoHubGetEngineersData.createEngineersData(load("engineers"));
+        assertNotNull(engResponse);
+
+        // test device ID (type 12 thermostat device)
+        assertEquals(12, engResponse.getDeviceType("Dining Room"));
+
+        // test device ID (type 6 plug device)
+        assertEquals(6, engResponse.getDeviceType("Living Room South"));
+    }
+
+    /**
+     * send JSON request to the socket and retrieve JSON response
+     */
+    private String testCommunicationInner(String requestJson) {
+        NeoHubConfiguration config = new NeoHubConfiguration();
+        config.hostName = HUB_IP_ADDRESS;
+        config.socketTimeout = 5;
+        try {
+            NeoHubSocket socket = new NeoHubSocket(config);
+            String responseJson = socket.sendMessage(requestJson);
+            socket.close();
+            return responseJson;
+        } catch (Exception e) {
+            assertTrue(false);
+        }
+        return "";
+    }
+
+    /**
+     * Test the communications
+     */
+    @Test
+    public void testCommunications() {
+        /*
+         * tests the actual communication with a real physical device on 'hubIpAddress'
+         * note: only run the test if such a device is actually available
+         */
+        if (!VALID_IP_V4_ADDRESS.matcher(HUB_IP_ADDRESS).matches()) {
+            return;
+        }
+
+        String responseJson = testCommunicationInner(CMD_CODE_INFO);
+        assertFalse(responseJson.isEmpty());
+
+        responseJson = testCommunicationInner(CMD_CODE_READ_DCB);
+        assertFalse(responseJson.isEmpty());
+
+        NeoHubReadDcbResponse dcbResponse = NeoHubReadDcbResponse.createSystemData(responseJson);
+        assertNotNull(dcbResponse);
+
+        long timeStamp = dcbResponse.timeStamp;
+        assertEquals(Instant.now().getEpochSecond(), timeStamp, 1);
+
+        responseJson = testCommunicationInner(CMD_CODE_GET_LIVE_DATA);
+        assertFalse(responseJson.isEmpty());
+
+        NeoHubLiveDeviceData liveDataResponse = NeoHubLiveDeviceData.createDeviceData(responseJson);
+        assertNotNull(liveDataResponse);
+
+        assertTrue(timeStamp > liveDataResponse.getTimestampEngineers());
+        assertTrue(timeStamp > liveDataResponse.getTimestampSystem());
+
+        responseJson = testCommunicationInner(CMD_CODE_GET_ENGINEERS);
+        assertFalse(responseJson.isEmpty());
+
+        responseJson = testCommunicationInner(CMD_CODE_GET_SYSTEM);
+        assertFalse(responseJson.isEmpty());
+
+        responseJson = testCommunicationInner(String.format(CMD_CODE_TEMP, "20", "Hallway"));
+        assertFalse(responseJson.isEmpty());
+    }
+}
diff --git a/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubProtocolTests.java b/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubProtocolTests.java
new file mode 100644 (file)
index 0000000..86cc7ae
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * Copyright (c) 2010-2022 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.binding.neohub.test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.IOException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.neohub.internal.NeoHubBindingConstants;
+import org.openhab.binding.neohub.internal.NeoHubConfiguration;
+import org.openhab.binding.neohub.internal.NeoHubException;
+import org.openhab.binding.neohub.internal.NeoHubSocket;
+import org.openhab.binding.neohub.internal.NeoHubWebSocket;
+
+/**
+ * JUnit for testing WSS and TCP socket protocols.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class NeoHubProtocolTests {
+
+    /**
+     * Test online communication. Requires an actual Neohub to be present on the LAN. Configuration parameters must be
+     * entered for the actual specific Neohub instance as follows:
+     *
+     * - HUB_IP_ADDRESS the dotted ip address of the hub
+     * - HUB_API_TOKEN the api access token for the hub
+     * - SOCKET_TIMEOUT the connection time out
+     * - RUN_WSS_TEST enable testing the WSS communication
+     * - RUN_TCP_TEST enable testing the TCP communication
+     *
+     * NOTE: only run these tests if a device is actually available
+     *
+     */
+    private static final String HUB_IP_ADDRESS = "192.168.1.xxx";
+    private static final String HUB_API_TOKEN = "12345678-1234-1234-1234-123456789ABC";
+    private static final int SOCKET_TIMEOUT = 5;
+    private static final boolean RUN_WSS_TEST = false;
+    private static final boolean RUN_TCP_TEST = false;
+
+    /**
+     * Use web socket to send a request, and check for a response.
+     *
+     * @throws NeoHubException
+     * @throws IOException
+     */
+    @Test
+    void testWssConnection() throws NeoHubException, IOException {
+        if (RUN_WSS_TEST) {
+            if (!NeoHubJsonTests.VALID_IP_V4_ADDRESS.matcher(HUB_IP_ADDRESS).matches()) {
+                fail();
+            }
+
+            NeoHubConfiguration config = new NeoHubConfiguration();
+            config.hostName = HUB_IP_ADDRESS;
+            config.socketTimeout = SOCKET_TIMEOUT;
+            config.apiToken = HUB_API_TOKEN;
+
+            NeoHubWebSocket socket = new NeoHubWebSocket(config);
+            String requestJson = NeoHubBindingConstants.CMD_CODE_FIRMWARE;
+            String responseJson = socket.sendMessage(requestJson);
+            assertNotEquals(0, responseJson.length());
+            socket.close();
+        }
+    }
+
+    /**
+     * Use TCP socket to send a request, and check for a response.
+     *
+     * @throws NeoHubException
+     * @throws IOException
+     */
+    @Test
+    void testTcpConnection() throws IOException, NeoHubException {
+        if (RUN_TCP_TEST) {
+            if (!NeoHubJsonTests.VALID_IP_V4_ADDRESS.matcher(HUB_IP_ADDRESS).matches()) {
+                fail();
+            }
+
+            NeoHubConfiguration config = new NeoHubConfiguration();
+            config.hostName = HUB_IP_ADDRESS;
+            config.socketTimeout = SOCKET_TIMEOUT;
+            config.apiToken = HUB_API_TOKEN;
+
+            NeoHubSocket socket = new NeoHubSocket(config);
+            String requestJson = NeoHubBindingConstants.CMD_CODE_FIRMWARE;
+            String responseJson = socket.sendMessage(requestJson);
+            assertNotEquals(0, responseJson.length());
+            socket.close();
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubTestData.java b/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubTestData.java
deleted file mode 100644 (file)
index 0422c1f..0000000
+++ /dev/null
@@ -1,423 +0,0 @@
-/**
- * Copyright (c) 2010-2022 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.binding.neohub.test;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.openhab.binding.neohub.internal.NeoHubBindingConstants.*;
-
-import java.io.BufferedReader;
-import java.io.FileReader;
-import java.io.IOException;
-import java.math.BigDecimal;
-import java.time.Instant;
-import java.util.regex.Pattern;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.junit.jupiter.api.Test;
-import org.openhab.binding.neohub.internal.NeoHubAbstractDeviceData;
-import org.openhab.binding.neohub.internal.NeoHubAbstractDeviceData.AbstractRecord;
-import org.openhab.binding.neohub.internal.NeoHubGetEngineersData;
-import org.openhab.binding.neohub.internal.NeoHubInfoResponse;
-import org.openhab.binding.neohub.internal.NeoHubInfoResponse.InfoRecord;
-import org.openhab.binding.neohub.internal.NeoHubLiveDeviceData;
-import org.openhab.binding.neohub.internal.NeoHubReadDcbResponse;
-import org.openhab.binding.neohub.internal.NeoHubSocket;
-import org.openhab.core.library.unit.ImperialUnits;
-import org.openhab.core.library.unit.SIUnits;
-
-/**
- * The {@link NeoHubTestData} class defines common constants, which are used
- * across the whole binding.
- *
- * @author Andrew Fiddian-Green - Initial contribution
- */
-@NonNullByDefault
-public class NeoHubTestData {
-
-    /*
-     * to actually run tests on a physical device you must have a hub physically available, and its IP address must be
-     * correctly configured in the "hubIPAddress" string constant e.g. "192.168.1.123"
-     * note: only run the test if such a device is actually available
-     */
-    private static final String hubIpAddress = "192.168.1.xxx";
-
-    private static final Pattern VALID_IP_V4_ADDRESS = Pattern
-            .compile("\\b((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}\\b");
-
-    /*
-     * Load the test JSON payload string from a file
-     */
-    private String load(String fileName) {
-        try (FileReader file = new FileReader(String.format("src/test/resources/%s.json", fileName));
-                BufferedReader reader = new BufferedReader(file)) {
-            StringBuilder builder = new StringBuilder();
-            String line;
-            while ((line = reader.readLine()) != null) {
-                builder.append(line).append("\n");
-            }
-            return builder.toString();
-        } catch (IOException e) {
-            fail(e.getMessage());
-        }
-        return "";
-    }
-
-    /*
-     * Test an INFO JSON response string as produced by older firmware versions
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testInfoJsonOld() {
-        // load INFO JSON response string in old JSON format
-        NeoHubAbstractDeviceData infoResponse = NeoHubInfoResponse.createDeviceData(load("info_old"));
-        assertNotNull(infoResponse);
-
-        // missing device
-        AbstractRecord device = infoResponse.getDeviceRecord("Aardvark");
-        assertNull(device);
-
-        // existing type 12 thermostat device
-        device = infoResponse.getDeviceRecord("Dining Room");
-        assertNotNull(device);
-        assertEquals("Dining Room", device.getDeviceName());
-        assertEquals(new BigDecimal("22.0"), device.getTargetTemperature());
-        assertEquals(new BigDecimal("22.2"), device.getActualTemperature());
-        assertEquals(new BigDecimal("23"), device.getFloorTemperature());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(12, ((InfoRecord) device).getDeviceType());
-        assertFalse(device.isStandby());
-        assertFalse(device.isHeating());
-        assertFalse(device.isPreHeating());
-        assertFalse(device.isTimerOn());
-        assertFalse(device.offline());
-        assertFalse(device.stateManual());
-        assertTrue(device.stateAuto());
-        assertFalse(device.isWindowOpen());
-        assertFalse(device.isBatteryLow());
-
-        // existing type 6 plug device (MANUAL OFF)
-        device = infoResponse.getDeviceRecord("Plug South");
-        assertNotNull(device);
-        assertEquals("Plug South", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(6, ((InfoRecord) device).getDeviceType());
-        assertFalse(device.isTimerOn());
-        assertTrue(device.stateManual());
-
-        // existing type 6 plug device (MANUAL ON)
-        device = infoResponse.getDeviceRecord("Plug North");
-        assertNotNull(device);
-        assertEquals("Plug North", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(6, ((InfoRecord) device).getDeviceType());
-        assertTrue(device.isTimerOn());
-        assertTrue(device.stateManual());
-
-        // existing type 6 plug device (AUTO OFF)
-        device = infoResponse.getDeviceRecord("Watering System");
-        assertNotNull(device);
-        assertEquals("Watering System", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(6, ((InfoRecord) device).getDeviceType());
-        assertFalse(device.isTimerOn());
-        assertFalse(device.stateManual());
-    }
-
-    /*
-     * Test an INFO JSON response string as produced by newer firmware versions
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testInfoJsonNew() {
-        // load INFO JSON response string in new JSON format
-        NeoHubAbstractDeviceData infoResponse = NeoHubInfoResponse.createDeviceData(load("info_new"));
-        assertNotNull(infoResponse);
-
-        // existing device (new JSON format)
-        AbstractRecord device = infoResponse.getDeviceRecord("Dining Room");
-        assertNotNull(device);
-        assertEquals("Dining Room", device.getDeviceName());
-        assertFalse(device.offline());
-        assertFalse(device.isWindowOpen());
-
-        // existing repeater device
-        device = infoResponse.getDeviceRecord("repeaternode54473");
-        assertNotNull(device);
-        assertEquals("repeaternode54473", device.getDeviceName());
-        assertEquals(new BigDecimal("127"), device.getFloorTemperature());
-        assertEquals(new BigDecimal("255.255"), device.getActualTemperature());
-    }
-
-    /*
-     * Test for a READ_DCB JSON string that has valid CORF C response
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testReadDcbJson() {
-        // load READ_DCB JSON response string with valid CORF C response
-        NeoHubReadDcbResponse dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_celsius"));
-        assertNotNull(dcbResponse);
-        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
-        assertEquals("2134", dcbResponse.getFirmwareVersion());
-
-        // load READ_DCB JSON response string with valid CORF F response
-        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_fahrenheit"));
-        assertNotNull(dcbResponse);
-        assertEquals(ImperialUnits.FAHRENHEIT, dcbResponse.getTemperatureUnit());
-
-        // load READ_DCB JSON response string with missing CORF element
-        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_corf_missing"));
-        assertNotNull(dcbResponse);
-        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
-
-        // load READ_DCB JSON response string where CORF element is an empty string
-        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("dcb_corf_empty"));
-        assertNotNull(dcbResponse);
-        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
-    }
-
-    /*
-     * Test an INFO JSON string that has a door contact and a temperature sensor
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testInfoJsonWithSensors() {
-        /*
-         * load an INFO JSON response string that has a closed door contact and a
-         * temperature sensor
-         */
-        // save("info_sensors_closed", NEOHUB_JSON_TEST_STRING_INFO_SENSORS_CLOSED);
-        NeoHubAbstractDeviceData infoResponse = NeoHubInfoResponse.createDeviceData(load("info_sensors_closed"));
-        assertNotNull(infoResponse);
-
-        // existing contact device type 5 (CLOSED)
-        AbstractRecord device = infoResponse.getDeviceRecord("Back Door");
-        assertNotNull(device);
-        assertEquals("Back Door", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(5, ((InfoRecord) device).getDeviceType());
-        assertFalse(device.isWindowOpen());
-        assertFalse(device.isBatteryLow());
-
-        // existing temperature sensor type 14
-        device = infoResponse.getDeviceRecord("Master Bedroom");
-        assertNotNull(device);
-        assertEquals("Master Bedroom", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(14, ((InfoRecord) device).getDeviceType());
-        assertEquals(new BigDecimal("19.5"), device.getActualTemperature());
-
-        // existing thermostat type 1
-        device = infoResponse.getDeviceRecord("Living Room Floor");
-        assertNotNull(device);
-        assertEquals("Living Room Floor", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(1, ((InfoRecord) device).getDeviceType());
-        assertEquals(new BigDecimal("19.8"), device.getActualTemperature());
-
-        // load an INFO JSON response string that has an open door contact
-        // save("info_sensors_open", NEOHUB_JSON_TEST_STRING_INFO_SENSORS_OPEN);
-        infoResponse = NeoHubInfoResponse.createDeviceData(load("info_sensors_open"));
-        assertNotNull(infoResponse);
-
-        // existing contact device type 5 (OPEN)
-        device = infoResponse.getDeviceRecord("Back Door");
-        assertNotNull(device);
-        assertEquals("Back Door", device.getDeviceName());
-        assertTrue(device instanceof InfoRecord);
-        assertEquals(5, ((InfoRecord) device).getDeviceType());
-        assertTrue(device.isWindowOpen());
-        assertTrue(device.isBatteryLow());
-    }
-
-    /*
-     * From NeoHub rev2.6 onwards the READ_DCB command is "deprecated" so we can
-     * also test the replacement GET_SYSTEM command (valid CORF response)
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testGetSystemJson() {
-        // load GET_SYSTEM JSON response string
-        NeoHubReadDcbResponse dcbResponse;
-        dcbResponse = NeoHubReadDcbResponse.createSystemData(load("system"));
-        assertNotNull(dcbResponse);
-        assertEquals(SIUnits.CELSIUS, dcbResponse.getTemperatureUnit());
-        assertEquals("2134", dcbResponse.getFirmwareVersion());
-    }
-
-    /*
-     * From NeoHub rev2.6 onwards the INFO command is "deprecated" so we must test
-     * the replacement GET_LIVE_DATA command
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testGetLiveDataJson() {
-        // load GET_LIVE_DATA JSON response string
-        NeoHubLiveDeviceData liveDataResponse = NeoHubLiveDeviceData.createDeviceData(load("live_data"));
-        assertNotNull(liveDataResponse);
-
-        // test the time stamps
-        assertEquals(1588494785, liveDataResponse.getTimestampEngineers());
-        assertEquals(0, liveDataResponse.getTimestampSystem());
-
-        // missing device
-        AbstractRecord device = liveDataResponse.getDeviceRecord("Aardvark");
-        assertNull(device);
-
-        // test an existing thermostat device
-        device = liveDataResponse.getDeviceRecord("Dining Room");
-        assertNotNull(device);
-        assertEquals("Dining Room", device.getDeviceName());
-        assertEquals(new BigDecimal("22.0"), device.getTargetTemperature());
-        assertEquals(new BigDecimal("22.2"), device.getActualTemperature());
-        assertEquals(new BigDecimal("20.50"), device.getFloorTemperature());
-        assertFalse(device.isStandby());
-        assertFalse(device.isHeating());
-        assertFalse(device.isPreHeating());
-        assertFalse(device.isTimerOn());
-        assertFalse(device.offline());
-        assertFalse(device.stateManual());
-        assertTrue(device.stateAuto());
-        assertFalse(device.isWindowOpen());
-        assertFalse(device.isBatteryLow());
-
-        // test a plug device (MANUAL OFF)
-        device = liveDataResponse.getDeviceRecord("Living Room South");
-        assertNotNull(device);
-        assertEquals("Living Room South", device.getDeviceName());
-        assertFalse(device.isTimerOn());
-        assertTrue(device.stateManual());
-
-        // test a plug device (MANUAL ON)
-        device = liveDataResponse.getDeviceRecord("Living Room North");
-        assertNotNull(device);
-        assertEquals("Living Room North", device.getDeviceName());
-        assertTrue(device.isTimerOn());
-        assertTrue(device.stateManual());
-
-        // test a plug device (AUTO OFF)
-        device = liveDataResponse.getDeviceRecord("Green Wall Watering");
-        assertNotNull(device);
-        assertEquals("Green Wall Watering", device.getDeviceName());
-        assertFalse(device.isTimerOn());
-        assertFalse(device.stateManual());
-
-        // test a device that is offline
-        device = liveDataResponse.getDeviceRecord("Shower Room");
-        assertNotNull(device);
-        assertEquals("Shower Room", device.getDeviceName());
-        assertTrue(device.offline());
-
-        // test a device with a low battery
-        device = liveDataResponse.getDeviceRecord("Conservatory");
-        assertNotNull(device);
-        assertEquals("Conservatory", device.getDeviceName());
-        assertTrue(device.isBatteryLow());
-
-        // test a device with an open window alarm
-        device = liveDataResponse.getDeviceRecord("Door Contact");
-        assertNotNull(device);
-        assertEquals("Door Contact", device.getDeviceName());
-        assertTrue(device.isWindowOpen());
-
-        // test a wireless temperature sensor
-        device = liveDataResponse.getDeviceRecord("Room Sensor");
-        assertNotNull(device);
-        assertEquals("Room Sensor", device.getDeviceName());
-        assertEquals(new BigDecimal("21.5"), device.getActualTemperature());
-
-        // test a repeater node
-        device = liveDataResponse.getDeviceRecord("repeaternode54473");
-        assertNotNull(device);
-        assertEquals("repeaternode54473", device.getDeviceName());
-        assertTrue(MATCHER_HEATMISER_REPEATER.matcher(device.getDeviceName()).matches());
-    }
-
-    /*
-     * From NeoHub rev2.6 onwards the INFO command is "deprecated" and the DEVICE_ID
-     * element is not returned in the GET_LIVE_DATA call so we must test the
-     * replacement GET_ENGINEERS command
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testGetEngineersJson() {
-        // load GET_ENGINEERS JSON response string
-        NeoHubGetEngineersData engResponse = NeoHubGetEngineersData.createEngineersData(load("engineers"));
-        assertNotNull(engResponse);
-
-        // test device ID (type 12 thermostat device)
-        assertEquals(12, engResponse.getDeviceType("Dining Room"));
-
-        // test device ID (type 6 plug device)
-        assertEquals(6, engResponse.getDeviceType("Living Room South"));
-    }
-
-    /*
-     * send JSON request to the socket and retrieve JSON response
-     */
-    private String testCommunicationInner(String requestJson) {
-        NeoHubSocket socket = new NeoHubSocket(hubIpAddress, 4242, 5);
-        String responseJson = "";
-        try {
-            responseJson = socket.sendMessage(requestJson);
-        } catch (Exception e) {
-            assertTrue(false);
-        }
-        return responseJson;
-    }
-
-    /*
-     * Test the communications
-     */
-    @SuppressWarnings("null")
-    @Test
-    public void testCommunications() {
-        /*
-         * tests the actual communication with a real physical device on 'hubIpAddress'
-         * note: only run the test if such a device is actually available
-         */
-        if (!VALID_IP_V4_ADDRESS.matcher(hubIpAddress).matches()) {
-            return;
-        }
-
-        String responseJson = testCommunicationInner(CMD_CODE_INFO);
-        assertFalse(responseJson.isEmpty());
-
-        responseJson = testCommunicationInner(CMD_CODE_READ_DCB);
-        assertFalse(responseJson.isEmpty());
-
-        NeoHubReadDcbResponse dcbResponse = NeoHubReadDcbResponse.createSystemData(responseJson);
-        assertNotNull(dcbResponse);
-
-        long timeStamp = dcbResponse.timeStamp;
-        assertEquals(Instant.now().getEpochSecond(), timeStamp, 1);
-
-        responseJson = testCommunicationInner(CMD_CODE_GET_LIVE_DATA);
-        assertFalse(responseJson.isEmpty());
-
-        NeoHubLiveDeviceData liveDataResponse = NeoHubLiveDeviceData.createDeviceData(responseJson);
-        assertNotNull(liveDataResponse);
-
-        assertTrue(timeStamp > liveDataResponse.getTimestampEngineers());
-        assertTrue(timeStamp > liveDataResponse.getTimestampSystem());
-
-        responseJson = testCommunicationInner(CMD_CODE_GET_ENGINEERS);
-        assertFalse(responseJson.isEmpty());
-
-        responseJson = testCommunicationInner(CMD_CODE_GET_SYSTEM);
-        assertFalse(responseJson.isEmpty());
-
-        responseJson = testCommunicationInner(String.format(CMD_CODE_TEMP, "20", "Hallway"));
-        assertFalse(responseJson.isEmpty());
-    }
-}