]> git.basschouten.com Git - openhab-addons.git/blob
a99b7cd79544b2030c4994224deb2df631455516
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.io.openhabcloud.internal;
14
15 import java.io.IOException;
16 import java.net.MalformedURLException;
17 import java.net.URI;
18 import java.net.URISyntaxException;
19 import java.net.URL;
20 import java.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.util.Iterator;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Optional;
26 import java.util.Set;
27 import java.util.concurrent.ConcurrentHashMap;
28 import java.util.concurrent.ScheduledExecutorService;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.atomic.AtomicReference;
32
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.eclipse.jetty.client.HttpClient;
35 import org.eclipse.jetty.client.api.Request;
36 import org.eclipse.jetty.client.util.BytesContentProvider;
37 import org.eclipse.jetty.http.HttpField;
38 import org.eclipse.jetty.http.HttpFields;
39 import org.eclipse.jetty.http.HttpMethod;
40 import org.eclipse.jetty.http.HttpStatus;
41 import org.eclipse.jetty.util.BufferUtil;
42 import org.eclipse.jetty.util.URIUtil;
43 import org.json.JSONArray;
44 import org.json.JSONException;
45 import org.json.JSONObject;
46 import org.openhab.core.OpenHAB;
47 import org.openhab.core.common.ThreadPoolManager;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import io.socket.backo.Backoff;
52 import io.socket.client.IO;
53 import io.socket.client.IO.Options;
54 import io.socket.client.Manager;
55 import io.socket.client.Socket;
56 import io.socket.emitter.Emitter;
57 import io.socket.engineio.client.Transport;
58 import io.socket.engineio.client.transports.WebSocket;
59 import io.socket.parser.Packet;
60 import io.socket.parser.Parser;
61 import okhttp3.OkHttpClient.Builder;
62 import okhttp3.logging.HttpLoggingInterceptor;
63 import okhttp3.logging.HttpLoggingInterceptor.Level;
64
65 /**
66  * This class provides communication between openHAB and the openHAB Cloud service.
67  * It also implements async http proxy for serving requests from user to
68  * openHAB through the openHAB Cloud. It uses Socket.IO connection to connect to
69  * the openHAB Cloud service and Jetty Http client to send local http requests to
70  * openHAB.
71  *
72  * @author Victor Belov - Initial contribution
73  * @author Kai Kreuzer - migrated code to new Jetty client and ESH APIs
74  * @author Dan Cunningham - Extended notification enhancements
75  */
76 public class CloudClient {
77
78     private static final long RECONNECT_MIN = 2_000;
79
80     private static final long RECONNECT_MAX = 60_000;
81
82     private static final double RECONNECT_JITTER = 0.75;
83
84     private static final long READ_TIMEOUT = 60_0000;
85
86     /*
87      * Logger for this class
88      */
89     private final Logger logger = LoggerFactory.getLogger(CloudClient.class);
90
91     /*
92      * This variable holds base URL for the openHAB Cloud connections
93      */
94     private final String baseURL;
95
96     /*
97      * This variable holds openHAB's UUID for authenticating and connecting to the openHAB Cloud
98      */
99     private final String uuid;
100
101     /*
102      * This variable holds openHAB's secret for authenticating and connecting to the openHAB Cloud
103      */
104     private final String secret;
105
106     /*
107      * This variable holds local openHAB's base URL for connecting to the local openHAB instance
108      */
109     private final String localBaseUrl;
110
111     /*
112      * This variable holds instance of Jetty HTTP client to make requests to local openHAB
113      */
114     private final HttpClient jettyClient;
115
116     /*
117      * This map holds HTTP requests to local openHAB which are currently running
118      */
119     private final Map<Integer, Request> runningRequests = new ConcurrentHashMap<>();
120
121     /*
122      * This variable indicates if connection to the openHAB Cloud is currently in an established state
123      */
124     private boolean isConnected;
125
126     /*
127      * This variable holds instance of Socket.IO client class which provides communication
128      * with the openHAB Cloud
129      */
130     private Socket socket;
131
132     /*
133      * The protocol of the openHAB-cloud URL.
134      */
135     private String protocol = "https";
136
137     /*
138      * This variable holds instance of CloudClientListener which provides callbacks to communicate
139      * certain events from the openHAB Cloud back to openHAB
140      */
141     private CloudClientListener listener;
142     private boolean remoteAccessEnabled;
143     private Set<String> exposedItems;
144
145     /**
146      * Back-off strategy for reconnecting when manual reconnection is needed
147      */
148     private final Backoff reconnectBackoff = new Backoff();
149
150     /*
151      * Delay reconnect scheduler pool
152      *
153      */
154     protected final ScheduledExecutorService scheduler = ThreadPoolManager
155             .getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
156
157     @SuppressWarnings("null")
158     private final AtomicReference<Optional<ScheduledFuture<?>>> reconnectFuture = new AtomicReference<>(
159             Optional.empty());
160
161     /**
162      * Constructor of CloudClient
163      *
164      * @param uuid openHAB's UUID to connect to the openHAB Cloud
165      * @param secret openHAB's Secret to connect to the openHAB Cloud
166      * @param remoteAccessEnabled Allow the openHAB Cloud to be used as a remote proxy
167      * @param exposedItems Items that are made available to apps connected to the openHAB Cloud
168      */
169     public CloudClient(HttpClient httpClient, String uuid, String secret, String baseURL, String localBaseUrl,
170             boolean remoteAccessEnabled, Set<String> exposedItems) {
171         this.uuid = uuid;
172         this.secret = secret;
173         this.baseURL = baseURL;
174         this.localBaseUrl = localBaseUrl;
175         this.remoteAccessEnabled = remoteAccessEnabled;
176         this.exposedItems = exposedItems;
177         this.jettyClient = httpClient;
178         reconnectBackoff.setMin(RECONNECT_MIN);
179         reconnectBackoff.setMax(RECONNECT_MAX);
180         reconnectBackoff.setJitter(RECONNECT_JITTER);
181     }
182
183     /**
184      * Connect to the openHAB Cloud
185      */
186
187     public void connect() {
188         try {
189             Options options = new Options();
190             options.transports = new String[] { WebSocket.NAME };
191             options.reconnection = true;
192             options.reconnectionAttempts = Integer.MAX_VALUE;
193             options.reconnectionDelay = RECONNECT_MIN;
194             options.reconnectionDelayMax = RECONNECT_MAX;
195             options.randomizationFactor = RECONNECT_JITTER;
196             options.timeout = READ_TIMEOUT;
197             Builder okHttpBuilder = new Builder();
198             okHttpBuilder.readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS);
199             if (logger.isTraceEnabled()) {
200                 // When trace level logging is enabled, we activate further logging of HTTP calls
201                 // of the Socket.IO library
202                 HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
203                 loggingInterceptor.setLevel(Level.BASIC);
204                 okHttpBuilder.addInterceptor(loggingInterceptor);
205                 okHttpBuilder.addNetworkInterceptor(loggingInterceptor);
206             }
207             options.callFactory = okHttpBuilder.build();
208             options.webSocketFactory = okHttpBuilder.build();
209             socket = IO.socket(baseURL, options);
210             URL parsed = new URL(baseURL);
211             protocol = parsed.getProtocol();
212         } catch (URISyntaxException e) {
213             logger.error("Error creating Socket.IO: {}", e.getMessage());
214             return;
215         } catch (MalformedURLException e) {
216             logger.error("Error parsing baseURL to get protocol, assuming https. Error: {}", e.getMessage());
217             return;
218         }
219         //
220         // socket manager events
221         //
222         socket.io()//
223                 .on(Manager.EVENT_TRANSPORT, args -> {
224                     logger.trace("Manager.EVENT_TRANSPORT");
225                     Transport transport = (Transport) args[0];
226                     transport.on(Transport.EVENT_REQUEST_HEADERS, new Emitter.Listener() {
227                         @Override
228                         public void call(Object... args) {
229                             logger.trace("Transport.EVENT_REQUEST_HEADERS");
230                             @SuppressWarnings("unchecked")
231                             Map<String, List<String>> headers = (Map<String, List<String>>) args[0];
232                             headers.put("uuid", List.of(uuid));
233                             headers.put("secret", List.of(secret));
234                             headers.put("openhabversion", List.of(OpenHAB.getVersion()));
235                             headers.put("clientversion", List.of(CloudService.clientVersion));
236                             headers.put("remoteaccess", List.of(((Boolean) remoteAccessEnabled).toString()));
237                         }
238                     });
239                 })//
240                 .on(Manager.EVENT_CONNECT_ERROR, args -> {
241                     if (args.length > 0) {
242                         if (args[0] instanceof Exception e) {
243                             logger.debug(
244                                     "Error connecting to the openHAB Cloud instance: {} {}. Should reconnect automatically.",
245                                     e.getClass().getSimpleName(), e.getMessage());
246                         } else {
247                             logger.debug(
248                                     "Error connecting to the openHAB Cloud instance: {}. Should reconnect automatically.",
249                                     args[0]);
250                         }
251                     } else {
252                         logger.debug("Error connecting to the openHAB Cloud instance. Should reconnect automatically.");
253                     }
254                 })//
255                 .on(Manager.EVENT_OPEN, args -> logger.debug("Socket.IO OPEN"))//
256                 .on(Manager.EVENT_CLOSE, args -> logger.debug("Socket.IO CLOSE: {}", args[0]))//
257                 .on(Manager.EVENT_PACKET, args -> {
258                     int packetTypeIndex = -1;
259                     String type = "<unexpected packet type>";
260                     if (args.length == 1 && args[0] instanceof Packet<?> packet) {
261                         packetTypeIndex = packet.type;
262
263                         if (packetTypeIndex < Parser.types.length) {
264                             type = Parser.types[packetTypeIndex];
265                         } else {
266                             type = "<unknown type>";
267                         }
268                     }
269                     logger.trace("Socket.IO Packet: {} ({})", type, packetTypeIndex);
270                 })//
271         ;
272
273         //
274         // socket events
275         //
276         socket.on(Socket.EVENT_CONNECT, args -> {
277             logger.debug("Socket.IO connected");
278             isConnected = true;
279             onConnect();
280         })//
281                 .on(Socket.EVENT_CONNECTING, args -> logger.debug("Socket.IO connecting"))//
282                 .on(Socket.EVENT_RECONNECTING, args -> logger.debug("Socket.IO re-connecting (attempt {})", args[0]))//
283                 .on(Socket.EVENT_RECONNECT,
284                         args -> logger.debug("Socket.IO re-connected successfully (attempt {})", args[0]))//
285                 .on(Socket.EVENT_RECONNECT_ERROR, args -> {
286                     if (args[0] instanceof Exception e) {
287                         logger.debug("Socket.IO re-connect attempt error: {} {}", e.getClass().getSimpleName(),
288                                 e.getMessage());
289                     } else {
290                         logger.debug("Socket.IO re-connect attempt error: {}", args[0]);
291                     }
292                 })//
293                 .on(Socket.EVENT_RECONNECT_FAILED,
294                         args -> logger.debug("Socket.IO re-connect attempts failed. Stopping reconnection."))//
295                 .on(Socket.EVENT_DISCONNECT, args -> {
296                     String message = args.length > 0 ? args[0].toString() : "";
297                     logger.warn("Socket.IO disconnected: {}", message);
298                     isConnected = false;
299                     onDisconnect();
300                     // https://github.com/socketio/socket.io-client/commit/afb952d854e1d8728ce07b7c3a9f0dee2a61ef4e
301                     if ("io server disconnect".equals(message)) {
302                         socket.close();
303                         long delay = reconnectBackoff.duration();
304                         logger.warn("Reconnecting after {} ms.", delay);
305                         scheduleReconnect(delay);
306                     }
307                 })//
308                 .on(Socket.EVENT_ERROR, args -> {
309                     if (CloudClient.this.socket.connected()) {
310                         if (args.length > 0) {
311                             if (args[0] instanceof Exception e) {
312                                 logger.warn("Error during communication: {} {}", e.getClass().getSimpleName(),
313                                         e.getMessage());
314                             } else {
315                                 logger.warn("Error during communication: {}", args[0]);
316                             }
317                         } else {
318                             logger.warn("Error during communication");
319                         }
320                     } else {
321                         // We are not connected currently, manual reconnection is needed to keep trying to
322                         // (re-)establish
323                         // connection.
324                         //
325                         // Socket.IO 1.x java client: 'error' event is emitted from Socket on connection errors that
326                         // are not
327                         // retried, but also with error that are automatically retried. If we
328                         //
329                         // Note how this is different in Socket.IO 2.x java client, Socket emits 'connect_error'
330                         // event.
331                         // OBS: Don't get confused with Socket IO 2.x docs online, in 1.x connect_error is emitted
332                         // also on
333                         // errors that are retried by the library automatically!
334                         long delay = reconnectBackoff.duration();
335                         // Try reconnecting on connection errors
336                         if (args.length > 0) {
337                             if (args[0] instanceof Exception e) {
338                                 logger.warn(
339                                         "Error connecting to the openHAB Cloud instance: {} {}. Reconnecting after {} ms.",
340                                         e.getClass().getSimpleName(), e.getMessage(), delay);
341                             } else {
342                                 logger.warn(
343                                         "Error connecting to the openHAB Cloud instance: {}. Reconnecting after {} ms.",
344                                         args[0], delay);
345                             }
346                         } else {
347                             logger.warn("Error connecting to the openHAB Cloud instance. Reconnecting.");
348                         }
349                         socket.close();
350                         scheduleReconnect(delay);
351                     }
352                 })//
353
354                 .on(Socket.EVENT_PING, args -> logger.debug("Socket.IO ping"))//
355                 .on(Socket.EVENT_PONG, args -> logger.debug("Socket.IO pong: {} ms", args[0]))//
356                 .on("request", args -> onEvent("request", (JSONObject) args[0]))//
357                 .on("cancel", args -> onEvent("cancel", (JSONObject) args[0]))//
358                 .on("command", args -> onEvent("command", (JSONObject) args[0]))//
359         ;
360         socket.connect();
361     }
362
363     /**
364      * Callback method for socket.io client which is called when connection is established
365      */
366
367     public void onConnect() {
368         logger.info("Connected to the openHAB Cloud service (UUID = {}, base URL = {})", censored(this.uuid),
369                 this.localBaseUrl);
370         reconnectBackoff.reset();
371         isConnected = true;
372     }
373
374     /**
375      * Callback method for socket.io client which is called when disconnect occurs
376      */
377
378     public void onDisconnect() {
379         logger.info("Disconnected from the openHAB Cloud service (UUID = {}, base URL = {})", censored(this.uuid),
380                 this.localBaseUrl);
381         isConnected = false;
382         // And clean up the list of running requests
383         runningRequests.clear();
384     }
385
386     /**
387      * Callback method for socket.io client which is called when a message is received
388      */
389
390     public void onEvent(String event, JSONObject data) {
391         logger.debug("on(): {}", event);
392         if ("command".equals(event)) {
393             handleCommandEvent(data);
394             return;
395         }
396         if (remoteAccessEnabled) {
397             if ("request".equals(event)) {
398                 handleRequestEvent(data);
399             } else if ("cancel".equals(event)) {
400                 handleCancelEvent(data);
401             } else {
402                 logger.warn("Unsupported event from openHAB Cloud: {}", event);
403             }
404         }
405     }
406
407     private void handleRequestEvent(JSONObject data) {
408         try {
409             // Get unique request Id
410             int requestId = data.getInt("id");
411             logger.debug("Got request {}", requestId);
412             // Get request path
413             String requestPath = data.getString("path");
414             logger.debug("Path {}", requestPath);
415             // Get request method
416             String requestMethod = data.getString("method");
417             logger.debug("Method {}", requestMethod);
418             // Get JSONObject for request headers
419             JSONObject requestHeadersJson = data.getJSONObject("headers");
420             logger.debug("Headers: {}", requestHeadersJson.toString());
421             // Get request body
422             String requestBody = data.getString("body");
423             logger.trace("Body {}", requestBody);
424             // Get JSONObject for request query parameters
425             JSONObject requestQueryJson = data.getJSONObject("query");
426             logger.debug("Query {}", requestQueryJson.toString());
427             // Create URI builder with base request URI of openHAB and path from request
428             String newPath = URIUtil.addPaths(localBaseUrl, requestPath);
429             Iterator<String> queryIterator = requestQueryJson.keys();
430             // Add query parameters to URI builder, if any
431             newPath += "?";
432             while (queryIterator.hasNext()) {
433                 String queryName = queryIterator.next();
434                 newPath += queryName;
435                 newPath += "=";
436                 newPath += URLEncoder.encode(requestQueryJson.getString(queryName), "UTF-8");
437                 if (queryIterator.hasNext()) {
438                     newPath += "&";
439                 }
440             }
441             // Finally get the future request URI
442             URI requestUri = new URI(newPath);
443             // All preparations which are common for different methods are done
444             // Now perform the request to openHAB
445             // If method is GET
446             logger.debug("Request method is {}", requestMethod);
447             Request request = jettyClient.newRequest(requestUri);
448             setRequestHeaders(request, requestHeadersJson);
449             String proto = protocol;
450             if (data.has("protocol")) {
451                 proto = data.getString("protocol");
452             }
453             request.header("X-Forwarded-Proto", proto);
454             HttpMethod method = HttpMethod.fromString(requestMethod);
455             if (method == null) {
456                 logger.debug("Unsupported request method {}", requestMethod);
457                 return;
458             }
459             request.method(method);
460             if (!requestBody.isEmpty()) {
461                 request.content(new BytesContentProvider(requestBody.getBytes()));
462             }
463
464             request.onResponseHeaders(response -> {
465                 logger.debug("onHeaders {}", requestId);
466                 JSONObject responseJson = new JSONObject();
467                 try {
468                     responseJson.put("id", requestId);
469                     responseJson.put("headers", getJSONHeaders(response.getHeaders()));
470                     responseJson.put("responseStatusCode", response.getStatus());
471                     responseJson.put("responseStatusText", "OK");
472                     socket.emit("responseHeader", responseJson);
473                     logger.trace("Sent headers to request {}", requestId);
474                     logger.trace("{}", responseJson.toString());
475                 } catch (JSONException e) {
476                     logger.debug("{}", e.getMessage());
477                 }
478             }).onResponseContent((theResponse, content) -> {
479                 logger.debug("onResponseContent: {}, content size {}", requestId, String.valueOf(content.remaining()));
480                 JSONObject responseJson = new JSONObject();
481                 try {
482                     responseJson.put("id", requestId);
483                     responseJson.put("body", BufferUtil.toArray(content));
484                     if (logger.isTraceEnabled()) {
485                         logger.trace("{}", StandardCharsets.UTF_8.decode(content).toString());
486                     }
487                     socket.emit("responseContentBinary", responseJson);
488                     logger.trace("Sent content to request {}", requestId);
489                 } catch (JSONException e) {
490                     logger.debug("{}", e.getMessage());
491                 }
492             }).onRequestFailure((origRequest, failure) -> {
493                 logger.debug("onRequestFailure: {},  {}", requestId, failure.getMessage());
494                 JSONObject responseJson = new JSONObject();
495                 try {
496                     responseJson.put("id", requestId);
497                     responseJson.put("responseStatusText", "openHAB connection error: " + failure.getMessage());
498                     socket.emit("responseError", responseJson);
499                 } catch (JSONException e) {
500                     logger.debug("{}", e.getMessage());
501                 }
502             }).send(result -> {
503                 logger.debug("onComplete: {}", requestId);
504                 // Remove this request from list of running requests
505                 runningRequests.remove(requestId);
506                 if ((result != null && result.isFailed())
507                         && (result.getResponse() != null && result.getResponse().getStatus() != HttpStatus.OK_200)) {
508                     if (result.getFailure() != null) {
509                         logger.debug("Jetty request {} failed: {}", requestId, result.getFailure().getMessage());
510                     }
511                     if (result.getRequestFailure() != null) {
512                         logger.debug("Request Failure: {}", result.getRequestFailure().getMessage());
513                     }
514                     if (result.getResponseFailure() != null) {
515                         logger.debug("Response Failure: {}", result.getResponseFailure().getMessage());
516                     }
517                 }
518                 JSONObject responseJson = new JSONObject();
519                 try {
520                     responseJson.put("id", requestId);
521                     socket.emit("responseFinished", responseJson);
522                     logger.debug("Finished responding to request {}", requestId);
523                 } catch (JSONException e) {
524                     logger.debug("{}", e.getMessage());
525                 }
526             });
527
528             // If successfully submitted request to http client, add it to the list of currently
529             // running requests to be able to cancel it if needed
530             runningRequests.put(requestId, request);
531         } catch (JSONException | IOException | URISyntaxException e) {
532             logger.debug("{}", e.getMessage());
533         }
534     }
535
536     private void setRequestHeaders(Request request, JSONObject requestHeadersJson) {
537         Iterator<String> headersIterator = requestHeadersJson.keys();
538         // Convert JSONObject of headers into Header ArrayList
539         while (headersIterator.hasNext()) {
540             String headerName = headersIterator.next();
541             String headerValue;
542             try {
543                 headerValue = requestHeadersJson.getString(headerName);
544                 logger.debug("Jetty set header {} = {}", headerName, headerValue);
545                 if (!"Content-Length".equalsIgnoreCase(headerName)) {
546                     request.header(headerName, headerValue);
547                 }
548             } catch (JSONException e) {
549                 logger.warn("Error processing request headers: {}", e.getMessage());
550             }
551         }
552     }
553
554     private void handleCancelEvent(JSONObject data) {
555         try {
556             int requestId = data.getInt("id");
557             logger.debug("Received cancel for request {}", requestId);
558             // Find and abort running request
559             Request request = runningRequests.get(requestId);
560             if (request != null) {
561                 request.abort(new InterruptedException());
562                 runningRequests.remove(requestId);
563             }
564         } catch (JSONException e) {
565             logger.debug("{}", e.getMessage());
566         }
567     }
568
569     private void handleCommandEvent(JSONObject data) {
570         String itemName = data.getString("item");
571         if (exposedItems.contains(itemName)) {
572             try {
573                 logger.debug("Received command {} for item {}.", data.getString("command"), itemName);
574                 if (this.listener != null) {
575                     this.listener.sendCommand(itemName, data.getString("command"));
576                 }
577             } catch (JSONException e) {
578                 logger.debug("{}", e.getMessage());
579             }
580         } else {
581             logger.warn("Received command from openHAB Cloud for item '{}', which is not exposed.", itemName);
582         }
583     }
584
585     /**
586      * This method sends notification to the openHAB Cloud
587      *
588      * @param userId openHAB Cloud user id
589      * @param message notification message text
590      * @param icon name of the icon for this notification
591      * @param severity severity name for this notification
592      * @param title for the notification
593      * @param onClickAction the action to perform when clicked
594      * @param mediaAttachmentUrl the media to attach to a notification
595      * @param actionButton1 an action button in the format "Title=Action"
596      * @param actionButton2 an action button in the format "Title=Action"
597      * @param actionButton3 an action button in the format "Title=Action"
598      */
599     public void sendNotification(String userId, String message, @Nullable String icon, @Nullable String severity,
600             @Nullable String title, @Nullable String onClickAction, @Nullable String mediaAttachmentUrl,
601             @Nullable String actionButton1, @Nullable String actionButton2, @Nullable String actionButton3) {
602         sendNotificationInternal(userId, message, icon, severity, title, onClickAction, mediaAttachmentUrl,
603                 actionButton1, actionButton2, actionButton3);
604     }
605
606     /**
607      * This method sends broadcast notification to the openHAB Cloud
608      *
609      * @param message notification message text
610      * @param icon name of the icon for this notification
611      * @param severity severity name for this notification
612      * @param title for this notification
613      * @param onClickAction the action to perform when clicked
614      * @param mediaAttachmentUrl the media to attach to a notification
615      * @param actionButton1 an action button in the format "Title=Action"
616      * @param actionButton2 an action button in the format "Title=Action"
617      * @param actionButton3 an action button in the format "Title=Action"
618      */
619     public void sendBroadcastNotification(String message, @Nullable String icon, @Nullable String severity,
620             @Nullable String title, @Nullable String onClickAction, @Nullable String mediaAttachmentUrl,
621             @Nullable String actionButton1, @Nullable String actionButton2, @Nullable String actionButton3) {
622         sendNotificationInternal(null, message, icon, severity, title, onClickAction, mediaAttachmentUrl, actionButton1,
623                 actionButton2, actionButton3);
624     }
625
626     private void sendNotificationInternal(@Nullable String userId, String message, @Nullable String icon,
627             @Nullable String severity, @Nullable String title, @Nullable String onClickAction,
628             @Nullable String mediaAttachmentUrl, @Nullable String actionButton1, @Nullable String actionButton2,
629             @Nullable String actionButton3) {
630         if (isConnected()) {
631             JSONObject notificationMessage = new JSONObject();
632             try {
633                 if (userId != null) {
634                     notificationMessage.put("userId", userId);
635                 }
636                 notificationMessage.put("message", message);
637                 notificationMessage.put("icon", icon);
638                 notificationMessage.put("severity", severity);
639                 if (title != null) {
640                     notificationMessage.put("title", title);
641                 }
642                 if (onClickAction != null) {
643                     notificationMessage.put("on-click", onClickAction);
644                 }
645                 if (mediaAttachmentUrl != null) {
646                     notificationMessage.put("media-attachment-url", mediaAttachmentUrl);
647                 }
648                 JSONArray actionArray = createActionArray(actionButton1, actionButton2, actionButton3);
649                 if (!actionArray.isEmpty()) {
650                     notificationMessage.put("actions", actionArray);
651                 }
652                 socket.emit(userId == null ? "broadcastnotification" : "notification", notificationMessage);
653             } catch (JSONException e) {
654                 logger.debug("{}", e.getMessage());
655             }
656         } else {
657             logger.debug("No connection, notification is not sent");
658         }
659     }
660
661     /**
662      * This method sends log notification to the openHAB Cloud
663      *
664      * @param message notification message text
665      * @param icon name of the icon for this notification
666      * @param severity severity name for this notification
667      */
668     public void sendLogNotification(String message, @Nullable String icon, @Nullable String severity) {
669         if (isConnected()) {
670             JSONObject notificationMessage = new JSONObject();
671             try {
672                 notificationMessage.put("message", message);
673                 notificationMessage.put("icon", icon);
674                 notificationMessage.put("severity", severity);
675                 socket.emit("lognotification", notificationMessage);
676             } catch (JSONException e) {
677                 logger.debug("{}", e.getMessage());
678             }
679         } else {
680             logger.debug("No connection, notification is not sent");
681         }
682     }
683
684     /**
685      * Send item update to openHAB Cloud
686      *
687      * @param itemName the name of the item
688      * @param itemState updated item state
689      *
690      */
691     public void sendItemUpdate(String itemName, String itemState) {
692         if (isConnected()) {
693             logger.debug("Sending update '{}' for item '{}'", itemState, itemName);
694             JSONObject itemUpdateMessage = new JSONObject();
695             try {
696                 itemUpdateMessage.put("itemName", itemName);
697                 itemUpdateMessage.put("itemStatus", itemState);
698                 socket.emit("itemupdate", itemUpdateMessage);
699             } catch (JSONException e) {
700                 logger.debug("{}", e.getMessage());
701             }
702         } else {
703             logger.debug("No connection, Item update is not sent");
704         }
705     }
706
707     /**
708      * Returns true if openHAB Cloud connection is active
709      */
710     public boolean isConnected() {
711         return isConnected;
712     }
713
714     /**
715      * Disconnect from openHAB Cloud
716      */
717     public void shutdown() {
718         logger.info("Shutting down openHAB Cloud service connection");
719         reconnectFuture.get().ifPresent(future -> future.cancel(true));
720         socket.disconnect();
721     }
722
723     public void setListener(CloudClientListener listener) {
724         this.listener = listener;
725     }
726
727     private void scheduleReconnect(long delay) {
728         reconnectFuture.getAndSet(Optional.of(scheduler.schedule(new Runnable() {
729             @Override
730             public void run() {
731                 socket.connect();
732             }
733         }, delay, TimeUnit.MILLISECONDS))).ifPresent(future -> future.cancel(true));
734     }
735
736     private JSONObject getJSONHeaders(HttpFields httpFields) {
737         JSONObject headersJSON = new JSONObject();
738         try {
739             for (HttpField field : httpFields) {
740                 headersJSON.put(field.getName(), field.getValue());
741             }
742         } catch (JSONException e) {
743             logger.warn("Error forming response headers: {}", e.getMessage());
744         }
745         return headersJSON;
746     }
747
748     private JSONArray createActionArray(@Nullable String... actionStrings) {
749         JSONArray actionArray = new JSONArray();
750         for (String actionString : actionStrings) {
751             if (actionString == null) {
752                 continue;
753             }
754             String[] parts = actionString.split("=", 2);
755             if (parts.length == 2) {
756                 JSONObject action = new JSONObject();
757                 action.put("title", parts[0]);
758                 action.put("action", parts[1]);
759                 actionArray.put(action);
760             }
761         }
762         return actionArray;
763     }
764
765     private static String censored(String secret) {
766         if (secret.length() < 4) {
767             return "*******";
768         }
769         return secret.substring(0, 2) + "..." + secret.substring(secret.length() - 2, secret.length());
770     }
771 }