From 4424bc4a15f6f1ad6fa15a572810774a5d33a261 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 1 Aug 2022 20:58:39 +0100 Subject: [PATCH] [neohub] Add support for WebSocket connection to hub (#12915) * [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 Signed-off-by: Andras Uhrin --- bundles/org.openhab.binding.neohub/README.md | 34 ++- .../internal/NeoHubBindingConstants.java | 12 + .../neohub/internal/NeoHubConfiguration.java | 2 + .../internal/NeoHubDiscoveryService.java | 8 +- .../neohub/internal/NeoHubException.java | 6 +- .../neohub/internal/NeoHubHandler.java | 38 ++- .../internal/NeoHubReadDcbResponse.java | 2 + .../binding/neohub/internal/NeoHubSocket.java | 47 ++-- .../neohub/internal/NeoHubSocketBase.java | 44 ++++ .../neohub/internal/NeoHubWebSocket.java | 238 ++++++++++++++++++ .../resources/OH-INF/i18n/neohub.properties | 6 +- .../resources/OH-INF/thing/thing-types.xml | 15 +- ...oHubTestData.java => NeoHubJsonTests.java} | 52 ++-- .../neohub/test/NeoHubProtocolTests.java | 106 ++++++++ 14 files changed, 521 insertions(+), 89 deletions(-) create mode 100644 bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocketBase.java create mode 100644 bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubWebSocket.java rename bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/{NeoHubTestData.java => NeoHubJsonTests.java} (95%) create mode 100644 bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubProtocolTests.java diff --git a/bundles/org.openhab.binding.neohub/README.md b/bundles/org.openhab.binding.neohub/README.md index 9b0b64eb174f3..ea8f85a5fea4b 100644 --- a/bundles/org.openhab.binding.neohub/README.md +++ b/bundles/org.openhab.binding.neohub/README.md @@ -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) | +| useWebSocket1) | Use secure WebSocket to connect to the NeoHub (example `true`) | +| apiToken1) | 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) | +| portNumber2) | ADVANCED: Port number for connection to the NeoHub (Default=0 (automatic)) | + +1) 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). + +2) 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" diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubBindingConstants.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubBindingConstants.java index 710328cf325b1..e4f950037d82d 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubBindingConstants.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubBindingConstants.java @@ -196,4 +196,16 @@ public static enum NeoHubReturnResult { 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"; } diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubConfiguration.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubConfiguration.java index 88977d1281afb..afebbce9112f2 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubConfiguration.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubConfiguration.java @@ -30,4 +30,6 @@ public class NeoHubConfiguration { public int pollingInterval; public int socketTimeout; public boolean preferLegacyApi; + public String apiToken = ""; + public boolean useWebSocket; } diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubDiscoveryService.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubDiscoveryService.java index ca6e2f28ff3d5..cff7cf137953e 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubDiscoveryService.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubDiscoveryService.java @@ -40,7 +40,7 @@ * Discovery service for neo devices * * @author Andrew Fiddian-Green - Initial contribution - * + * */ @NonNullByDefault public class NeoHubDiscoveryService extends AbstractDiscoveryService { @@ -113,11 +113,11 @@ private void discoverDevices() { // 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 @@ private void discoverDevices() { continue; } deviceType = engineerData.getDeviceType(deviceName); - publishDevice((LiveDataRecord) deviceRecord, deviceType); + publishDevice(deviceRecord, deviceType); } } } diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubException.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubException.java index 1541dac1333a7..55c4bd2a243b0 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubException.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubException.java @@ -18,7 +18,7 @@ * 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); + } } diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubHandler.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubHandler.java index be17626a9a719..15bb98ce1a745 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubHandler.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubHandler.java @@ -65,7 +65,7 @@ public class NeoHubHandler extends BaseBridgeHandler { private final Map 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 void initialize() { 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 void initialize() { 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 void dispose() { 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 void startFastPollingBurst() { * 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 synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandSt * @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 synchronized NeoHubReturnResult toNeoHubSendChannelValue(String commandSt * @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 @@ private void selectApi() { 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 @@ private void selectApi() { * get the Engineers data */ public @Nullable NeoHubGetEngineersData fromNeoHubGetEngineersData() { - NeoHubSocket socket = this.socket; + NeoHubSocketBase socket = this.socket; if (socket != null) { String responseJson; try { diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubReadDcbResponse.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubReadDcbResponse.java index 05d4f8f6b22c3..00e0cb7adbf7b 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubReadDcbResponse.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubReadDcbResponse.java @@ -59,9 +59,11 @@ public Unit getTemperatureUnit() { } public @Nullable String getFirmwareVersion() { + BigDecimal firmwareVersionNew = this.firmwareVersionNew; if (firmwareVersionNew != null) { return firmwareVersionNew.toString(); } + BigDecimal firmwareVersionOld = this.firmwareVersionOld; if (firmwareVersionOld != null) { return firmwareVersionOld.toString(); } diff --git a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocket.java b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocket.java index 7d289ef644125..301fefb8e1755 100644 --- a/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocket.java +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocket.java @@ -25,54 +25,30 @@ 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 String sendMessage(final String requestJson) throws IOException, NeoHubEx 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 index 0000000000000..3ee2b9665245e --- /dev/null +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubSocketBase.java @@ -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 index 0000000000000..f62dc860e5e16 --- /dev/null +++ b/bundles/org.openhab.binding.neohub/src/main/java/org/openhab/binding/neohub/internal/NeoHubWebSocket.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/i18n/neohub.properties b/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/i18n/neohub.properties index dddda2cf9d342..d29d025218702 100644 --- a/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/i18n/neohub.properties +++ b/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/i18n/neohub.properties @@ -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 diff --git a/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/thing/thing-types.xml index 1aa673651a8bc..75e9aef900926 100644 --- a/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.neohub/src/main/resources/OH-INF/thing/thing-types.xml @@ -28,8 +28,8 @@ - Port number of the NeoHub - 4242 + Override port number to use to connect to the NeoHub (0=automatic) + 0 true @@ -53,6 +53,17 @@ false true + + + + Select whether to communicate with the Neohub via WebSocket or TCP + false + + + + + API access token for the hub (created with the Heatmiser mobile app) + 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/NeoHubJsonTests.java similarity index 95% rename from bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubTestData.java rename to bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubJsonTests.java index 0422c1f7af483..0417e15359b39 100644 --- 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/NeoHubJsonTests.java @@ -26,6 +26,7 @@ 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; @@ -36,25 +37,24 @@ import org.openhab.core.library.unit.SIUnits; /** - * The {@link NeoHubTestData} class defines common constants, which are used - * across the whole binding. + * JUnit for testing JSON parsing. * * @author Andrew Fiddian-Green - Initial contribution */ @NonNullByDefault -public class NeoHubTestData { +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 hubIpAddress = "192.168.1.xxx"; + private static final String HUB_IP_ADDRESS = "192.168.1.xxx"; - private static final Pattern VALID_IP_V4_ADDRESS = Pattern + 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) { @@ -72,10 +72,9 @@ private String load(String fileName) { 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 @@ -133,10 +132,9 @@ public void testInfoJsonOld() { 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 @@ -158,10 +156,9 @@ public void testInfoJsonNew() { 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 @@ -186,10 +183,9 @@ public void testReadDcbJson() { 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() { /* @@ -240,11 +236,10 @@ public void testInfoJsonWithSensors() { 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 @@ -255,11 +250,10 @@ public void testGetSystemJson() { 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 @@ -343,12 +337,11 @@ public void testGetLiveDataJson() { 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 @@ -362,31 +355,34 @@ public void testGetEngineersJson() { 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 = ""; + NeoHubConfiguration config = new NeoHubConfiguration(); + config.hostName = HUB_IP_ADDRESS; + config.socketTimeout = 5; try { - responseJson = socket.sendMessage(requestJson); + NeoHubSocket socket = new NeoHubSocket(config); + String responseJson = socket.sendMessage(requestJson); + socket.close(); + return responseJson; } catch (Exception e) { assertTrue(false); } - return responseJson; + return ""; } - /* + /** * 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()) { + if (!VALID_IP_V4_ADDRESS.matcher(HUB_IP_ADDRESS).matches()) { return; } 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 index 0000000000000..86cc7ae79b1df --- /dev/null +++ b/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubProtocolTests.java @@ -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(); + } + } +}