2 * Copyright (c) 2010-2021 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.io.openhabcloud.internal;
15 import java.io.IOException;
16 import java.net.MalformedURLException;
18 import java.net.URISyntaxException;
20 import java.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.util.Iterator;
23 import java.util.List;
26 import java.util.concurrent.ConcurrentHashMap;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.Request;
31 import org.eclipse.jetty.client.util.BytesContentProvider;
32 import org.eclipse.jetty.http.HttpField;
33 import org.eclipse.jetty.http.HttpFields;
34 import org.eclipse.jetty.http.HttpMethod;
35 import org.eclipse.jetty.http.HttpStatus;
36 import org.eclipse.jetty.util.BufferUtil;
37 import org.eclipse.jetty.util.URIUtil;
38 import org.json.JSONException;
39 import org.json.JSONObject;
40 import org.openhab.core.OpenHAB;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
44 import io.socket.client.IO;
45 import io.socket.client.Manager;
46 import io.socket.client.Socket;
47 import io.socket.emitter.Emitter;
48 import io.socket.engineio.client.Transport;
51 * This class provides communication between openHAB and the openHAB Cloud service.
52 * It also implements async http proxy for serving requests from user to
53 * openHAB through the openHAB Cloud. It uses Socket.IO connection to connect to
54 * the openHAB Cloud service and Jetty Http client to send local http requests to
57 * @author Victor Belov - Initial contribution
58 * @author Kai Kreuzer - migrated code to new Jetty client and ESH APIs
60 public class CloudClient {
62 * Logger for this class
64 private final Logger logger = LoggerFactory.getLogger(CloudClient.class);
67 * This variable holds base URL for the openHAB Cloud connections
69 private final String baseURL;
72 * This variable holds openHAB's UUID for authenticating and connecting to the openHAB Cloud
74 private final String uuid;
77 * This variable holds openHAB's secret for authenticating and connecting to the openHAB Cloud
79 private final String secret;
82 * This variable holds local openHAB's base URL for connecting to the local openHAB instance
84 private final String localBaseUrl;
87 * This variable holds instance of Jetty HTTP client to make requests to local openHAB
89 private final HttpClient jettyClient;
92 * This map holds HTTP requests to local openHAB which are currently running
94 private final Map<Integer, Request> runningRequests = new ConcurrentHashMap<>();
97 * This variable indicates if connection to the openHAB Cloud is currently in an established state
99 private boolean isConnected;
102 * This variable holds version of local openHAB
104 private String openHABVersion;
107 * This variable holds instance of Socket.IO client class which provides communication
108 * with the openHAB Cloud
110 private Socket socket;
113 * The protocol of the openHAB-cloud URL.
115 private String protocol = "https";
118 * This variable holds instance of CloudClientListener which provides callbacks to communicate
119 * certain events from the openHAB Cloud back to openHAB
121 private CloudClientListener listener;
122 private boolean remoteAccessEnabled;
123 private Set<String> exposedItems;
126 * Constructor of CloudClient
128 * @param uuid openHAB's UUID to connect to the openHAB Cloud
129 * @param secret openHAB's Secret to connect to the openHAB Cloud
130 * @param remoteAccessEnabled Allow the openHAB Cloud to be used as a remote proxy
131 * @param exposedItems Items that are made available to apps connected to the openHAB Cloud
133 public CloudClient(HttpClient httpClient, String uuid, String secret, String baseURL, String localBaseUrl,
134 boolean remoteAccessEnabled, Set<String> exposedItems) {
136 this.secret = secret;
137 this.baseURL = baseURL;
138 this.localBaseUrl = localBaseUrl;
139 this.remoteAccessEnabled = remoteAccessEnabled;
140 this.exposedItems = exposedItems;
141 this.jettyClient = httpClient;
145 * Connect to the openHAB Cloud
148 public void connect() {
150 socket = IO.socket(baseURL);
151 URL parsed = new URL(baseURL);
152 protocol = parsed.getProtocol();
153 } catch (URISyntaxException e) {
154 logger.error("Error creating Socket.IO: {}", e.getMessage());
155 } catch (MalformedURLException e) {
156 logger.error("Error parsing baseURL to get protocol, assuming https. Error: {}", e.getMessage());
158 socket.io().on(Manager.EVENT_TRANSPORT, new Emitter.Listener() {
160 public void call(Object... args) {
161 logger.trace("Manager.EVENT_TRANSPORT");
162 Transport transport = (Transport) args[0];
163 transport.on(Transport.EVENT_REQUEST_HEADERS, new Emitter.Listener() {
165 public void call(Object... args) {
166 logger.trace("Transport.EVENT_REQUEST_HEADERS");
167 @SuppressWarnings("unchecked")
168 Map<String, List<String>> headers = (Map<String, List<String>>) args[0];
169 headers.put("uuid", List.of(uuid));
170 headers.put("secret", List.of(secret));
171 headers.put("openhabversion", List.of(OpenHAB.getVersion()));
172 headers.put("clientversion", List.of(CloudService.clientVersion));
173 headers.put("remoteaccess", List.of(((Boolean) remoteAccessEnabled).toString()));
178 socket.on(Socket.EVENT_CONNECT, new Emitter.Listener() {
180 public void call(Object... args) {
181 logger.debug("Socket.IO connected");
185 }).on(Socket.EVENT_DISCONNECT, new Emitter.Listener() {
187 public void call(Object... args) {
188 logger.debug("Socket.IO disconnected");
192 }).on(Socket.EVENT_ERROR, new Emitter.Listener() {
194 public void call(Object... args) {
195 if (logger.isDebugEnabled()) {
196 logger.error("Error connecting to the openHAB Cloud instance: {}", args[0]);
198 logger.error("Error connecting to the openHAB Cloud instance");
201 }).on("request", new Emitter.Listener() {
203 public void call(Object... args) {
204 onEvent("request", (JSONObject) args[0]);
206 }).on("cancel", new Emitter.Listener() {
208 public void call(Object... args) {
209 onEvent("cancel", (JSONObject) args[0]);
211 }).on("command", new Emitter.Listener() {
214 public void call(Object... args) {
215 onEvent("command", (JSONObject) args[0]);
222 * Callback method for socket.io client which is called when connection is established
225 public void onConnect() {
226 logger.info("Connected to the openHAB Cloud service (UUID = {}, base URL = {})", this.uuid, this.localBaseUrl);
231 * Callback method for socket.io client which is called when disconnect occurs
234 public void onDisconnect() {
235 logger.info("Disconnected from the openHAB Cloud service (UUID = {}, base URL = {})", this.uuid,
238 // And clean up the list of running requests
239 runningRequests.clear();
243 * Callback method for socket.io client which is called when an error occurs
246 public void onError(IOException error) {
247 logger.debug("{}", error.getMessage());
251 * Callback method for socket.io client which is called when a message is received
254 public void onEvent(String event, JSONObject data) {
255 logger.debug("on(): {}", event);
256 if ("command".equals(event)) {
257 handleCommandEvent(data);
260 if (remoteAccessEnabled) {
261 if ("request".equals(event)) {
262 handleRequestEvent(data);
263 } else if ("cancel".equals(event)) {
264 handleCancelEvent(data);
266 logger.warn("Unsupported event from openHAB Cloud: {}", event);
271 private void handleRequestEvent(JSONObject data) {
273 // Get unique request Id
274 int requestId = data.getInt("id");
275 logger.debug("Got request {}", requestId);
277 String requestPath = data.getString("path");
278 logger.debug("Path {}", requestPath);
279 // Get request method
280 String requestMethod = data.getString("method");
281 logger.debug("Method {}", requestMethod);
282 // Get JSONObject for request headers
283 JSONObject requestHeadersJson = data.getJSONObject("headers");
284 logger.debug("Headers: {}", requestHeadersJson.toString());
286 String requestBody = data.getString("body");
287 logger.trace("Body {}", requestBody);
288 // Get JSONObject for request query parameters
289 JSONObject requestQueryJson = data.getJSONObject("query");
290 logger.debug("Query {}", requestQueryJson.toString());
291 // Create URI builder with base request URI of openHAB and path from request
292 String newPath = URIUtil.addPaths(localBaseUrl, requestPath);
293 Iterator<String> queryIterator = requestQueryJson.keys();
294 // Add query parameters to URI builder, if any
296 while (queryIterator.hasNext()) {
297 String queryName = queryIterator.next();
298 newPath += queryName;
300 newPath += URLEncoder.encode(requestQueryJson.getString(queryName), "UTF-8");
301 if (queryIterator.hasNext()) {
305 // Finally get the future request URI
306 URI requestUri = new URI(newPath);
307 // All preparations which are common for different methods are done
308 // Now perform the request to openHAB
310 logger.debug("Request method is {}", requestMethod);
311 Request request = jettyClient.newRequest(requestUri);
312 setRequestHeaders(request, requestHeadersJson);
313 String proto = protocol;
314 if (data.has("protocol")) {
315 proto = data.getString("protocol");
317 request.header("X-Forwarded-Proto", proto);
319 if (requestMethod.equals("GET")) {
320 request.method(HttpMethod.GET);
321 } else if (requestMethod.equals("POST")) {
322 request.method(HttpMethod.POST);
323 request.content(new BytesContentProvider(requestBody.getBytes()));
324 } else if (requestMethod.equals("PUT")) {
325 request.method(HttpMethod.PUT);
326 request.content(new BytesContentProvider(requestBody.getBytes()));
328 // TODO: Reject unsupported methods
329 logger.warn("Unsupported request method {}", requestMethod);
333 request.onResponseHeaders(response -> {
334 logger.debug("onHeaders {}", requestId);
335 JSONObject responseJson = new JSONObject();
337 responseJson.put("id", requestId);
338 responseJson.put("headers", getJSONHeaders(response.getHeaders()));
339 responseJson.put("responseStatusCode", response.getStatus());
340 responseJson.put("responseStatusText", "OK");
341 socket.emit("responseHeader", responseJson);
342 logger.trace("Sent headers to request {}", requestId);
343 logger.trace("{}", responseJson.toString());
344 } catch (JSONException e) {
345 logger.debug("{}", e.getMessage());
347 }).onResponseContent((theResponse, content) -> {
348 logger.debug("onResponseContent: {}, content size {}", requestId, String.valueOf(content.remaining()));
349 JSONObject responseJson = new JSONObject();
351 responseJson.put("id", requestId);
352 responseJson.put("body", BufferUtil.toArray(content));
353 if (logger.isTraceEnabled()) {
354 logger.trace("{}", StandardCharsets.UTF_8.decode(content).toString());
356 socket.emit("responseContentBinary", responseJson);
357 logger.trace("Sent content to request {}", requestId);
358 } catch (JSONException e) {
359 logger.debug("{}", e.getMessage());
361 }).onRequestFailure((origRequest, failure) -> {
362 logger.debug("onRequestFailure: {}, {}", requestId, failure.getMessage());
363 JSONObject responseJson = new JSONObject();
365 responseJson.put("id", requestId);
366 responseJson.put("responseStatusText", "openHAB connection error: " + failure.getMessage());
367 socket.emit("responseError", responseJson);
368 } catch (JSONException e) {
369 logger.debug("{}", e.getMessage());
372 logger.debug("onComplete: {}", requestId);
373 // Remove this request from list of running requests
374 runningRequests.remove(requestId);
375 if ((result != null && result.isFailed())
376 && (result.getResponse() != null && result.getResponse().getStatus() != HttpStatus.OK_200)) {
377 if (result.getFailure() != null) {
378 logger.debug("Jetty request {} failed: {}", requestId, result.getFailure().getMessage());
380 if (result.getRequestFailure() != null) {
381 logger.debug("Request Failure: {}", result.getRequestFailure().getMessage());
383 if (result.getResponseFailure() != null) {
384 logger.debug("Response Failure: {}", result.getResponseFailure().getMessage());
387 JSONObject responseJson = new JSONObject();
389 responseJson.put("id", requestId);
390 socket.emit("responseFinished", responseJson);
391 logger.debug("Finished responding to request {}", requestId);
392 } catch (JSONException e) {
393 logger.debug("{}", e.getMessage());
397 // If successfully submitted request to http client, add it to the list of currently
398 // running requests to be able to cancel it if needed
399 runningRequests.put(requestId, request);
400 } catch (JSONException | IOException | URISyntaxException e) {
401 logger.debug("{}", e.getMessage());
405 private void setRequestHeaders(Request request, JSONObject requestHeadersJson) {
406 Iterator<String> headersIterator = requestHeadersJson.keys();
407 // Convert JSONObject of headers into Header ArrayList
408 while (headersIterator.hasNext()) {
409 String headerName = headersIterator.next();
412 headerValue = requestHeadersJson.getString(headerName);
413 logger.debug("Jetty set header {} = {}", headerName, headerValue);
414 if (!headerName.equalsIgnoreCase("Content-Length")) {
415 request.header(headerName, headerValue);
417 } catch (JSONException e) {
418 logger.warn("Error processing request headers: {}", e.getMessage());
423 private void handleCancelEvent(JSONObject data) {
425 int requestId = data.getInt("id");
426 logger.debug("Received cancel for request {}", requestId);
427 // Find and abort running request
428 Request request = runningRequests.get(requestId);
429 if (request != null) {
430 request.abort(new InterruptedException());
431 runningRequests.remove(requestId);
433 } catch (JSONException e) {
434 logger.debug("{}", e.getMessage());
438 private void handleCommandEvent(JSONObject data) {
439 String itemName = data.getString("item");
440 if (exposedItems.contains(itemName)) {
442 logger.debug("Received command {} for item {}.", data.getString("command"), itemName);
443 if (this.listener != null) {
444 this.listener.sendCommand(itemName, data.getString("command"));
446 } catch (JSONException e) {
447 logger.debug("{}", e.getMessage());
450 logger.warn("Received command from openHAB Cloud for item '{}', which is not exposed.", itemName);
455 * This method sends notification to the openHAB Cloud
457 * @param userId openHAB Cloud user id
458 * @param message notification message text
459 * @param icon name of the icon for this notification
460 * @param severity severity name for this notification
462 public void sendNotification(String userId, String message, @Nullable String icon, @Nullable String severity) {
464 JSONObject notificationMessage = new JSONObject();
466 notificationMessage.put("userId", userId);
467 notificationMessage.put("message", message);
468 notificationMessage.put("icon", icon);
469 notificationMessage.put("severity", severity);
470 socket.emit("notification", notificationMessage);
471 } catch (JSONException e) {
472 logger.debug("{}", e.getMessage());
475 logger.debug("No connection, notification is not sent");
480 * This method sends log notification to the openHAB Cloud
482 * @param message notification message text
483 * @param icon name of the icon for this notification
484 * @param severity severity name for this notification
486 public void sendLogNotification(String message, @Nullable String icon, @Nullable String severity) {
488 JSONObject notificationMessage = new JSONObject();
490 notificationMessage.put("message", message);
491 notificationMessage.put("icon", icon);
492 notificationMessage.put("severity", severity);
493 socket.emit("lognotification", notificationMessage);
494 } catch (JSONException e) {
495 logger.debug("{}", e.getMessage());
498 logger.debug("No connection, notification is not sent");
503 * This method sends broadcast notification to the openHAB Cloud
505 * @param message notification message text
506 * @param icon name of the icon for this notification
507 * @param severity severity name for this notification
509 public void sendBroadcastNotification(String message, @Nullable String icon, @Nullable String severity) {
511 JSONObject notificationMessage = new JSONObject();
513 notificationMessage.put("message", message);
514 notificationMessage.put("icon", icon);
515 notificationMessage.put("severity", severity);
516 socket.emit("broadcastnotification", notificationMessage);
517 } catch (JSONException e) {
518 logger.debug("{}", e.getMessage());
521 logger.debug("No connection, notification is not sent");
526 * Send item update to openHAB Cloud
528 * @param itemName the name of the item
529 * @param itemState updated item state
532 public void sendItemUpdate(String itemName, String itemState) {
534 logger.debug("Sending update '{}' for item '{}'", itemState, itemName);
535 JSONObject itemUpdateMessage = new JSONObject();
537 itemUpdateMessage.put("itemName", itemName);
538 itemUpdateMessage.put("itemStatus", itemState);
539 socket.emit("itemupdate", itemUpdateMessage);
540 } catch (JSONException e) {
541 logger.debug("{}", e.getMessage());
544 logger.debug("No connection, Item update is not sent");
549 * Returns true if openHAB Cloud connection is active
551 public boolean isConnected() {
556 * Disconnect from openHAB Cloud
558 public void shutdown() {
559 logger.info("Shutting down openHAB Cloud service connection");
563 public String getOpenHABVersion() {
564 return openHABVersion;
567 public void setOpenHABVersion(String openHABVersion) {
568 this.openHABVersion = openHABVersion;
571 public void setListener(CloudClientListener listener) {
572 this.listener = listener;
575 private JSONObject getJSONHeaders(HttpFields httpFields) {
576 JSONObject headersJSON = new JSONObject();
578 for (HttpField field : httpFields) {
579 headersJSON.put(field.getName(), field.getValue());
581 } catch (JSONException e) {
582 logger.warn("Error forming response headers: {}", e.getMessage());