From: Andrew Fiddian-Green Date: Mon, 1 Aug 2022 19:58:39 +0000 (+0100) Subject: [neohub] Add support for WebSocket connection to hub (#12915) X-Git-Url: https://git.basschouten.com/?a=commitdiff_plain;h=f60e324229c1b42e5d147669822e372ed607b650;p=openhab-addons.git [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 --- diff --git a/bundles/org.openhab.binding.neohub/README.md b/bundles/org.openhab.binding.neohub/README.md index 9b0b64eb17..ea8f85a5fe 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 710328cf32..e4f950037d 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 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"; } 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 88977d1281..afebbce911 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 ca6e2f28ff..cff7cf1379 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 @@ 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); } } } 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 1541dac133..55c4bd2a24 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 @@ 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); + } } 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 be17626a9a..15bb98ce1a 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 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 { 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 05d4f8f6b2..00e0cb7adb 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 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(); } 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 7d289ef644..301fefb8e1 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.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 index 0000000000..3ee2b96652 --- /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 0000000000..f62dc860e5 --- /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 dddda2cf9d..d29d025218 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 1aa673651a..75e9aef900 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/NeoHubJsonTests.java b/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubJsonTests.java new file mode 100644 index 0000000000..0417e15359 --- /dev/null +++ b/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubJsonTests.java @@ -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 index 0000000000..86cc7ae79b --- /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(); + } + } +} 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 index 0422c1f7af..0000000000 --- a/bundles/org.openhab.binding.neohub/src/test/java/org/openhab/binding/neohub/test/NeoHubTestData.java +++ /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()); - } -}