2 * Copyright (c) 2010-2024 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.time.Instant;
19 import java.util.concurrent.ExecutionException;
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
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.openhab.core.io.net.http.WebSocketFactory;
32 import org.openhab.core.thing.ThingUID;
33 import org.openhab.core.thing.util.ThingWebClientUtil;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
37 import com.google.gson.Gson;
38 import com.google.gson.JsonParser;
39 import com.google.gson.JsonSyntaxException;
42 * Handles the text based communication via web socket between openHAB and NeoHub
44 * @author Andrew Fiddian-Green - Initial contribution
49 public class NeoHubWebSocket extends NeoHubSocketBase {
51 private static final int SLEEP_MILLISECONDS = 100;
52 private static final String REQUEST_OUTER = "{\"message_type\":\"hm_get_command_queue\",\"message\":\"%s\"}";
53 private static final String REQUEST_INNER = "{\"token\":\"%s\",\"COMMANDS\":[{\"COMMAND\":\"%s\",\"COMMANDID\":1}]}";
55 private final Logger logger = LoggerFactory.getLogger(NeoHubWebSocket.class);
56 private final Gson gson = new Gson();
57 private final WebSocketClient webSocketClient;
59 private @Nullable Session session = null;
60 private String responseOuter = "";
61 private boolean responsePending;
64 * DTO to receive and parse the response JSON.
66 * @author Andrew Fiddian-Green - Initial contribution
68 private static class Response {
69 @SuppressWarnings("unused")
70 public @Nullable String command_id;
71 @SuppressWarnings("unused")
72 public @Nullable String device_id;
73 public @Nullable String message_type;
74 public @Nullable String response;
77 public NeoHubWebSocket(NeoHubConfiguration config, WebSocketFactory webSocketFactory, ThingUID bridgeUID)
79 super(config, bridgeUID.getAsString());
81 SslContextFactory.Client sslContextFactory = new SslContextFactory.Client();
82 sslContextFactory.setTrustAll(true);
83 String name = ThingWebClientUtil.buildWebClientConsumerName(bridgeUID, null);
84 webSocketClient = webSocketFactory.createWebSocketClient(name, sslContextFactory);
85 webSocketClient.setConnectTimeout(config.socketTimeout * 1000);
87 webSocketClient.start();
88 } catch (Exception e) {
89 throw new IOException("Error starting Web Socket client", e);
94 * Open the web socket session.
96 * @throws IOException if unable to open the web socket
98 private void startSession() throws IOException {
99 Session session = this.session;
100 if (session == null || !session.isOpen()) {
103 int port = config.portNumber > 0 ? config.portNumber : NeoHubBindingConstants.PORT_WSS;
104 URI uri = new URI(String.format("wss://%s:%d", config.hostName, port));
105 webSocketClient.connect(this, uri).get();
106 } catch (InterruptedException e) {
107 Thread.currentThread().interrupt();
108 throw new IOException("Error starting session", e);
109 } catch (ExecutionException | IOException | URISyntaxException e) {
110 throw new IOException("Error starting session", e);
116 * Close the web socket session.
118 private void closeSession() {
119 Session session = this.session;
121 if (session != null) {
127 * Helper to escape the quote marks in a JSON string.
129 * @param json the input JSON string.
130 * @return the escaped JSON version.
132 private String jsonEscape(String json) {
133 return json.replace("\"", "\\\"");
137 * Helper to remove quote escape marks from an escaped JSON string.
139 * @param escapedJson the escaped input string.
140 * @return the clean JSON version.
142 private String jsonUnEscape(String escapedJson) {
143 return escapedJson.replace("\\\"", "\"");
147 * Helper to replace double quote marks in a JSON string with single quote marks.
149 * @param json the input string.
150 * @return the modified version.
152 private String jsonReplaceQuotes(String json) {
153 return json.replace("\"", "'");
157 public synchronized String sendMessage(final String requestJson) throws IOException, NeoHubException {
161 // session start failed
162 Session session = this.session;
163 if (session == null) {
164 throw new IOException("Session is null");
167 // wrap the inner request in an outer request string
168 String requestOuter = String.format(REQUEST_OUTER,
169 jsonEscape(String.format(REQUEST_INNER, config.apiToken, jsonReplaceQuotes(requestJson))));
171 // initialise the response
173 responsePending = true;
175 IOException caughtException = null;
179 logger.debug("hub '{}' sending characters:{}", hubId, requestOuter.length());
180 session.getRemote().sendString(requestOuter);
181 logger.trace("hub '{}' sent:{}", hubId, requestOuter);
183 // sleep and loop until we get a response, the socket is closed, or it times out
184 Instant timeout = Instant.now().plusSeconds(config.socketTimeout);
185 while (responsePending) {
187 Thread.sleep(SLEEP_MILLISECONDS);
188 if (Instant.now().isAfter(timeout)) {
189 throw new IOException("Read timed out");
191 } catch (InterruptedException e) {
192 throw new IOException("Read interrupted", e);
195 } catch (IOException e) {
199 caughtException = caughtException != null ? caughtException
200 : this.session == null ? new IOException("WebSocket session closed") : null;
202 logger.debug("hub '{}' received characters:{}", hubId, responseOuter.length());
203 logger.trace("hub '{}' received:{}", hubId, responseOuter);
205 // if an IOException was caught above, re-throw it again
206 if (caughtException != null) {
207 throw caughtException;
211 Response responseDto = gson.fromJson(responseOuter, Response.class);
212 if (responseDto == null) {
213 throw new JsonSyntaxException("Response DTO is invalid");
215 if (!NeoHubBindingConstants.HM_SET_COMMAND_RESPONSE.equals(responseDto.message_type)) {
216 throw new JsonSyntaxException("DTO 'message_type' field is invalid");
218 String responseJson = responseDto.response;
219 if (responseJson == null) {
220 throw new JsonSyntaxException("DTO 'response' field is null");
222 responseJson = jsonUnEscape(responseJson).strip();
223 if (!JsonParser.parseString(responseJson).isJsonObject()) {
224 throw new JsonSyntaxException("DTO 'response' field is not a JSON object");
227 } catch (JsonSyntaxException e) {
228 logger.debug("hub '{}' {}; response:{}", hubId, e.getMessage(), responseOuter);
229 throw new NeoHubException("Invalid response");
234 public void close() {
237 webSocketClient.stop();
238 } catch (Exception e) {
243 public void onConnect(Session session) {
244 logger.debug("hub '{}' onConnect() ok", hubId);
245 this.session = session;
249 public void onClose(int statusCode, String reason) {
250 logger.debug("hub '{}' onClose() statusCode:{}, reason:{}", hubId, statusCode, reason);
251 responsePending = false;
256 public void onError(Throwable cause) {
257 logger.debug("hub '{}' onError() cause:{}", hubId, cause.getMessage());
262 public void onMessage(String msg) {
263 responseOuter = msg.strip();
264 responsePending = false;