2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.neohub.internal;
15 import java.io.IOException;
17 import java.net.URISyntaxException;
18 import java.util.concurrent.ExecutionException;
20 import org.eclipse.jdt.annotation.NonNullByDefault;
21 import org.eclipse.jdt.annotation.Nullable;
22 import org.eclipse.jetty.client.HttpClient;
23 import org.eclipse.jetty.util.ssl.SslContextFactory;
24 import org.eclipse.jetty.websocket.api.Session;
25 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
26 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
27 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
28 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
29 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
30 import org.eclipse.jetty.websocket.client.WebSocketClient;
31 import org.slf4j.Logger;
32 import org.slf4j.LoggerFactory;
34 import com.google.gson.Gson;
35 import com.google.gson.JsonParser;
36 import com.google.gson.JsonSyntaxException;
39 * Handles the text based communication via web socket between openHAB and NeoHub
41 * @author Andrew Fiddian-Green - Initial contribution
46 public class NeoHubWebSocket extends NeoHubSocketBase {
48 private static final int SLEEP_MILLISECONDS = 100;
49 private static final String REQUEST_OUTER = "{\"message_type\":\"hm_get_command_queue\",\"message\":\"%s\"}";
50 private static final String REQUEST_INNER = "{\"token\":\"%s\",\"COMMANDS\":[{\"COMMAND\":\"%s\",\"COMMANDID\":1}]}";
52 private final Logger logger = LoggerFactory.getLogger(NeoHubWebSocket.class);
53 private final Gson gson = new Gson();
54 private final WebSocketClient webSocketClient;
56 private @Nullable Session session = null;
57 private String responseOuter = "";
58 private boolean responsePending;
61 * DTO to receive and parse the response JSON.
63 * @author Andrew Fiddian-Green - Initial contribution
65 private static class Response {
66 @SuppressWarnings("unused")
67 public @Nullable String command_id;
68 @SuppressWarnings("unused")
69 public @Nullable String device_id;
70 public @Nullable String message_type;
71 public @Nullable String response;
74 public NeoHubWebSocket(NeoHubConfiguration config, String hubId) throws IOException {
77 // initialise and start ssl context factory, http client, web socket client
78 SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
79 sslContextFactory.setTrustAll(true);
80 HttpClient httpClient = new HttpClient(sslContextFactory);
83 } catch (Exception e) {
84 throw new IOException("Error starting HTTP client", e);
86 webSocketClient = new WebSocketClient(httpClient);
87 webSocketClient.setConnectTimeout(config.socketTimeout * 1000);
89 webSocketClient.start();
90 } catch (Exception e) {
91 throw new IOException("Error starting Web Socket client", e);
96 * Open the web socket session.
98 * @throws IOException if unable to open the web socket
100 private void startSession() throws IOException {
101 Session session = this.session;
102 if (session == null || !session.isOpen()) {
105 int port = config.portNumber > 0 ? config.portNumber : NeoHubBindingConstants.PORT_WSS;
106 URI uri = new URI(String.format("wss://%s:%d", config.hostName, port));
107 webSocketClient.connect(this, uri).get();
108 } catch (InterruptedException e) {
109 Thread.currentThread().interrupt();
110 throw new IOException("Error starting session", e);
111 } catch (ExecutionException | IOException | URISyntaxException e) {
112 throw new IOException("Error starting session", e);
118 * Close the web socket session.
120 private void closeSession() {
121 Session session = this.session;
122 if (session != null) {
129 * Helper to escape the quote marks in a JSON string.
131 * @param json the input JSON string.
132 * @return the escaped JSON version.
134 private String jsonEscape(String json) {
135 return json.replace("\"", "\\\"");
139 * Helper to remove quote escape marks from an escaped JSON string.
141 * @param escapedJson the escaped input string.
142 * @return the clean JSON version.
144 private String jsonUnEscape(String escapedJson) {
145 return escapedJson.replace("\\\"", "\"");
149 * Helper to replace double quote marks in a JSON string with single quote marks.
151 * @param json the input string.
152 * @return the modified version.
154 private String jsonReplaceQuotes(String json) {
155 return json.replace("\"", "'");
159 public synchronized String sendMessage(final String requestJson) throws IOException, NeoHubException {
163 // session start failed
164 Session session = this.session;
165 if (session == null) {
166 throw new IOException("Session is null");
169 // wrap the inner request in an outer request string
170 String requestOuter = String.format(REQUEST_OUTER,
171 jsonEscape(String.format(REQUEST_INNER, config.apiToken, jsonReplaceQuotes(requestJson))));
173 // initialise the response
175 responsePending = true;
177 IOException caughtException = null;
180 logger.debug("hub '{}' sending characters:{}", hubId, requestOuter.length());
181 session.getRemote().sendString(requestOuter);
182 logger.trace("hub '{}' sent:{}", hubId, requestOuter);
184 // sleep and loop until we get a response or the socket is closed
185 int sleepRemainingMilliseconds = config.socketTimeout * 1000;
186 while (responsePending) {
188 Thread.sleep(SLEEP_MILLISECONDS);
189 sleepRemainingMilliseconds = sleepRemainingMilliseconds - SLEEP_MILLISECONDS;
190 if (sleepRemainingMilliseconds <= 0) {
191 throw new IOException("Read timed out");
193 } catch (InterruptedException e) {
194 throw new IOException("Read interrupted", e);
197 } catch (IOException e) {
201 logger.debug("hub '{}' received characters:{}", hubId, responseOuter.length());
202 logger.trace("hub '{}' received:{}", hubId, responseOuter);
204 // if an IOException was caught above, re-throw it again
205 if (caughtException != null) {
206 throw caughtException;
210 Response responseDto = gson.fromJson(responseOuter, Response.class);
211 if (responseDto == null) {
212 throw new JsonSyntaxException("Response DTO is invalid");
214 if (!NeoHubBindingConstants.HM_SET_COMMAND_RESPONSE.equals(responseDto.message_type)) {
215 throw new JsonSyntaxException("DTO 'message_type' field is invalid");
217 String responseJson = responseDto.response;
218 if (responseJson == null) {
219 throw new JsonSyntaxException("DTO 'response' field is null");
221 responseJson = jsonUnEscape(responseJson).strip();
222 if (!JsonParser.parseString(responseJson).isJsonObject()) {
223 throw new JsonSyntaxException("DTO 'response' field is not a JSON object");
226 } catch (JsonSyntaxException e) {
227 logger.debug("hub '{}' {}; response:{}", hubId, e.getMessage(), responseOuter);
228 throw new NeoHubException("Invalid response");
233 public void close() {
236 webSocketClient.stop();
237 } catch (Exception e) {
242 public void onConnect(Session session) {
243 logger.debug("hub '{}' onConnect() ok", hubId);
244 this.session = session;
248 public void onClose(int statusCode, String reason) {
249 logger.debug("hub '{}' onClose() statusCode:{}, reason:{}", hubId, statusCode, reason);
250 responsePending = false;
255 public void onError(Throwable cause) {
256 logger.debug("hub '{}' onError() cause:{}", hubId, cause.getMessage());
261 public void onMessage(String msg) {
262 responseOuter = msg.strip();
263 responsePending = false;