]> git.basschouten.com Git - openhab-addons.git/blob
fa7da4fcdc3d890f1c48c73ed9e7531ac2cbcb0c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.binding.loxone.internal;
14
15 import java.io.BufferedReader;
16 import java.io.IOException;
17 import java.io.InputStreamReader;
18 import java.net.HttpURLConnection;
19 import java.net.InetAddress;
20 import java.net.URL;
21 import java.nio.ByteBuffer;
22 import java.nio.ByteOrder;
23 import java.util.concurrent.ScheduledExecutorService;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.locks.Condition;
27 import java.util.concurrent.locks.Lock;
28 import java.util.concurrent.locks.ReentrantLock;
29
30 import org.eclipse.jetty.websocket.api.Session;
31 import org.eclipse.jetty.websocket.api.StatusCode;
32 import org.eclipse.jetty.websocket.api.WebSocketPolicy;
33 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
34 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
35 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
36 import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
37 import org.eclipse.jetty.websocket.api.annotations.WebSocket;
38 import org.openhab.binding.loxone.internal.security.LxWsSecurity;
39 import org.openhab.binding.loxone.internal.types.LxConfig;
40 import org.openhab.binding.loxone.internal.types.LxErrorCode;
41 import org.openhab.binding.loxone.internal.types.LxResponse;
42 import org.openhab.binding.loxone.internal.types.LxUuid;
43 import org.openhab.binding.loxone.internal.types.LxWsBinaryHeader;
44 import org.openhab.binding.loxone.internal.types.LxWsSecurityType;
45 import org.openhab.core.common.ThreadPoolManager;
46 import org.openhab.core.util.HexUtils;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 import com.google.gson.Gson;
51 import com.google.gson.JsonParseException;
52
53 /**
54  * Implementation of jetty websocket client
55  *
56  * @author Pawel Pieczul - initial contribution
57  *
58  */
59 @WebSocket
60 public class LxWebSocket {
61     private static final String CMD_ACTION = "jdev/sps/io/";
62     private static final String CMD_KEEPALIVE = "keepalive";
63     private static final String CMD_ENABLE_UPDATES = "jdev/sps/enablebinstatusupdate";
64     private static final String CMD_GET_APP_CONFIG = "data/LoxAPP3.json";
65
66     private final int debugId;
67     private final Gson gson;
68     private final LxServerHandler thingHandler;
69
70     private long responseTimeout = 4; // 4 seconds to wait for Miniserver response
71     private int maxBinMsgSize = 3 * 1024; // 3 MB
72     private int maxTextMsgSize = 512; // 512 KB
73     private final LxWsSecurityType securityType;
74     private final InetAddress host;
75     private final int port;
76     private final String user;
77     private final String password;
78
79     private Session session;
80     private String fwVersion;
81     private ScheduledFuture<?> timeout;
82     private LxWsBinaryHeader header;
83     private LxWsSecurity security;
84     private boolean awaitingConfiguration = false;
85     private final Lock webSocketLock = new ReentrantLock();
86     private final Lock responseLock = new ReentrantLock();
87     private final Condition responseAvailable = responseLock.newCondition();
88     private String awaitingCommand;
89     private LxResponse awaitedResponse;
90     private boolean syncRequest;
91
92     private LxErrorCode offlineCode;
93     private String offlineReason;
94
95     private static final ScheduledExecutorService SCHEDULER = ThreadPoolManager
96             .getScheduledPool(LxWebSocket.class.getSimpleName());
97     private final Logger logger = LoggerFactory.getLogger(LxWebSocket.class);
98
99     /**
100      * Create websocket object.
101      *
102      * @param debugId instance of the client used for debugging purposes only
103      * @param thingHandler API to the thing handler
104      * @param cfg binding configuration
105      * @param host IP address of the Miniserver
106      */
107     LxWebSocket(int debugId, LxServerHandler thingHandler, LxBindingConfiguration cfg, InetAddress host) {
108         this.debugId = debugId;
109         this.thingHandler = thingHandler;
110         this.host = host;
111         this.port = cfg.port;
112         this.user = cfg.user;
113         this.password = cfg.password;
114         this.gson = thingHandler.getGson();
115
116         securityType = LxWsSecurityType.getType(cfg.authMethod);
117         if (cfg.responseTimeout > 0 && cfg.responseTimeout != responseTimeout) {
118             logger.debug("[{}] Changing responseTimeout to {}", debugId, cfg.responseTimeout);
119             responseTimeout = cfg.responseTimeout;
120         }
121         if (cfg.maxBinMsgSize > 0 && cfg.maxBinMsgSize != maxBinMsgSize) {
122             logger.debug("[{}] Changing maxBinMsgSize to {}", debugId, cfg.maxBinMsgSize);
123             maxBinMsgSize = cfg.maxBinMsgSize;
124         }
125         if (cfg.maxTextMsgSize > 0 && cfg.maxTextMsgSize != maxTextMsgSize) {
126             logger.debug("[{}] Changing maxTextMsgSize to {}", debugId, cfg.maxTextMsgSize);
127             maxTextMsgSize = cfg.maxTextMsgSize;
128         }
129     }
130
131     /*
132      * Jetty websocket methods
133      */
134
135     @OnWebSocketConnect
136     public void onConnect(Session session) {
137         webSocketLock.lock();
138         try {
139             offlineCode = null;
140             offlineReason = null;
141             WebSocketPolicy policy = session.getPolicy();
142             policy.setMaxBinaryMessageSize(maxBinMsgSize * 1024);
143             policy.setMaxTextMessageSize(maxTextMsgSize * 1024);
144
145             logger.debug("[{}] Websocket connected (maxBinMsgSize={}, maxTextMsgSize={})", debugId,
146                     policy.getMaxBinaryMessageSize(), policy.getMaxTextMessageSize());
147             this.session = session;
148
149             security = LxWsSecurity.create(securityType, fwVersion, debugId, thingHandler, this, user, password);
150             security.authenticate((result, details) -> {
151                 if (result == LxErrorCode.OK) {
152                     authenticated();
153                 } else {
154                     disconnect(result, details);
155                 }
156             });
157         } finally {
158             webSocketLock.unlock();
159         }
160     }
161
162     @OnWebSocketClose
163     public void onClose(int statusCode, String reason) {
164         String reasonToPass;
165         LxErrorCode codeToPass;
166         webSocketLock.lock();
167         try {
168             logger.debug("[{}] Websocket connection closed with code {} reason : {}", debugId, statusCode, reason);
169             if (security != null) {
170                 security.cancel();
171             }
172             session = null;
173             // This callback is called when connection is terminated by either end.
174             // If there is already a reason for disconnection, pass it unchanged.
175             // Otherwise try to interpret the remote end reason.
176             if (offlineCode != null) {
177                 codeToPass = offlineCode;
178                 reasonToPass = offlineReason;
179             } else {
180                 codeToPass = LxErrorCode.getErrorCode(statusCode);
181                 reasonToPass = reason;
182             }
183         } finally {
184             webSocketLock.unlock();
185         }
186
187         // Release any requester waiting for message response
188         responseLock.lock();
189         try {
190             if (awaitedResponse != null) {
191                 awaitedResponse.subResponse = null;
192             }
193             responseAvailable.signalAll();
194         } finally {
195             responseLock.unlock();
196         }
197         thingHandler.setOffline(codeToPass, reasonToPass);
198     }
199
200     @OnWebSocketError
201     public void onError(Throwable error) {
202         logger.debug("[{}] Websocket error : {}", debugId, error.getMessage());
203         // We do nothing. This callback may be called at various connection stages and indicates something wrong
204         // with the connection mostly on the protocol level. It will be caught by other activities - connection will
205         // be closed of timeouts will detect its inactivity.
206     }
207
208     @OnWebSocketMessage
209     public void onBinaryMessage(byte data[], int msgOffset, int msgLength) {
210         int offset = msgOffset;
211         int length = msgLength;
212         if (logger.isTraceEnabled()) {
213             String s = HexUtils.bytesToHex(data);
214             logger.trace("[{}] Binary message: length {}: {}", debugId, length, s);
215         }
216         webSocketLock.lock();
217         try {
218             // websocket will receive header and data in turns as two separate binary messages
219             if (header == null) {
220                 // header expected now
221                 header = new LxWsBinaryHeader(data, offset);
222                 switch (header.getType()) {
223                     // following header types precede data in next message
224                     case BINARY_FILE:
225                     case EVENT_TABLE_OF_VALUE_STATES:
226                     case EVENT_TABLE_OF_TEXT_STATES:
227                     case EVENT_TABLE_OF_DAYTIMER_STATES:
228                     case EVENT_TABLE_OF_WEATHER_STATES:
229                         break;
230                     // other header types have no data and next message will be header again
231                     default:
232                         header = null;
233                         break;
234                 }
235             } else {
236                 // data expected now
237                 switch (header.getType()) {
238                     case EVENT_TABLE_OF_VALUE_STATES:
239                         stopResponseTimeout();
240                         while (length > 0) {
241                             Double value = ByteBuffer.wrap(data, offset + 16, 8).order(ByteOrder.LITTLE_ENDIAN)
242                                     .getDouble();
243                             thingHandler.queueStateUpdate(new LxUuid(data, offset), value);
244                             offset += 24;
245                             length -= 24;
246                         }
247                         break;
248                     case EVENT_TABLE_OF_TEXT_STATES:
249                         while (length > 0) {
250                             // unused today at (offset + 16): iconUuid
251                             int textLen = ByteBuffer.wrap(data, offset + 32, 4).order(ByteOrder.LITTLE_ENDIAN).getInt();
252                             String value = new String(data, offset + 36, textLen);
253                             int size = 36 + (textLen % 4 > 0 ? textLen + 4 - (textLen % 4) : textLen);
254                             thingHandler.queueStateUpdate(new LxUuid(data, offset), value);
255                             offset += size;
256                             length -= size;
257                         }
258                         break;
259                     case KEEPALIVE_RESPONSE:
260                     case TEXT_MESSAGE:
261                     default:
262                         break;
263                 }
264                 // header will be next
265                 header = null;
266             }
267         } catch (IndexOutOfBoundsException e) {
268             logger.debug("[{}] malformed binary message received, discarded", debugId);
269         } finally {
270             webSocketLock.unlock();
271         }
272     }
273
274     @OnWebSocketMessage
275     public void onMessage(String msg) {
276         webSocketLock.lock();
277         try {
278             if (logger.isTraceEnabled()) {
279                 String trace = msg;
280                 if (trace.length() > 100) {
281                     trace = msg.substring(0, 100);
282                 }
283                 logger.trace("[{}] received message: {}", debugId, trace);
284             }
285             if (!awaitingConfiguration) {
286                 processResponse(msg);
287                 return;
288             }
289             awaitingConfiguration = false;
290             stopResponseTimeout();
291             thingHandler.clearConfiguration();
292
293             LxConfig config = gson.fromJson(msg, LxConfig.class);
294             config.finalize(thingHandler);
295
296             thingHandler.setMiniserverConfig(config);
297
298             if (sendCmdWithResp(CMD_ENABLE_UPDATES, false, false) == null) {
299                 disconnect(LxErrorCode.COMMUNICATION_ERROR, "Failed to enable state updates.");
300             }
301         } finally {
302             webSocketLock.unlock();
303         }
304     }
305
306     /*
307      * Public methods, called by {@link LxControl} and {@link LxWsSecurity} child classes
308      */
309
310     /**
311      * Parse received message into a response structure. Check basic correctness of the response.
312      *
313      * @param msg received response message
314      * @return parsed response message
315      */
316     public LxResponse getResponse(String msg) {
317         try {
318             LxResponse resp = gson.fromJson(msg, LxResponse.class);
319             if (!resp.isResponseOk()) {
320                 logger.debug("[{}] Miniserver response is not ok: {}", debugId, msg);
321                 return null;
322             }
323             return resp;
324         } catch (JsonParseException e) {
325             logger.debug("[{}] Miniserver response JSON parsing error: {}, {}", debugId, msg, e.getMessage());
326             return null;
327         }
328     }
329
330     /**
331      * Sends a command to the Miniserver and encrypts it if command can be encrypted and encryption is available.
332      * Request can be synchronous or asynchronous. There is always a response expected to the command, and it is a
333      * standard command response as defined in {@link LxResponse}. Such commands are the majority of commands
334      * used for performing actions on the controls and for executing authentication procedure.
335      * A synchronous command must not be sent from the websocket thread (from websocket callback methods) or it will
336      * cause a deadlock.
337      * An asynchronous command request returns immediately, but the returned value will not contain valid data in
338      * the subResponse structure until a response is received. Asynchronous request can be sent from the websocket
339      * thread. There can be only one command sent which awaits response per websocket connection,
340      * whether this is synchronous or asynchronous command (this seems how Loxone Miniserver behaves, as it does not
341      * have any unique identifier to match commands to responses).
342      * For synchronous commands this is ensured naturally, for asynchronous the caller must manage it.
343      * If this method is called before a response to the previous command is received, it will return error and not
344      * send the command.
345      *
346      * @param command command to send to the Miniserver
347      * @param sync true is synchronous request, false if ansynchronous
348      * @param encrypt true if command can be encrypted (does not mean it will)
349      * @return response received (for sync command) or to be received (for async), null if error occurred
350      */
351     public LxResponse sendCmdWithResp(String command, boolean sync, boolean encrypt) {
352         responseLock.lock();
353         try {
354             if (awaitedResponse != null || awaitingCommand != null) {
355                 logger.warn("[{}] Command not sent, previous command not finished: {}", debugId, command);
356                 return null;
357             }
358             if (!sendCmdNoResp(command, encrypt)) {
359                 return null;
360             }
361             LxResponse resp = new LxResponse();
362             awaitingCommand = command;
363             awaitedResponse = resp;
364             syncRequest = sync;
365             if (sync) {
366                 if (!responseAvailable.await(responseTimeout, TimeUnit.SECONDS)) {
367                     awaitedResponse = null;
368                     awaitingCommand = null;
369                     responseTimeout();
370                     return null;
371                 }
372                 awaitedResponse = null;
373                 awaitingCommand = null;
374             }
375             return resp;
376         } catch (InterruptedException e) {
377             logger.debug("[{}] Interrupted waiting for response: {}", debugId, command);
378             awaitedResponse = null;
379             awaitingCommand = null;
380             return null;
381         } finally {
382             responseLock.unlock();
383         }
384     }
385
386     /**
387      * Send a HTTP GET request and return server's response.
388      *
389      * @param request request content
390      * @return response received
391      */
392     public String httpGet(String request) {
393         HttpURLConnection con = null;
394         try {
395             URL url = new URL("http", host.getHostAddress(), port, request.startsWith("/") ? request : "/" + request);
396             con = (HttpURLConnection) url.openConnection();
397             con.setRequestMethod("GET");
398             StringBuilder result = new StringBuilder();
399             try (BufferedReader reader = new BufferedReader(new InputStreamReader(con.getInputStream()))) {
400                 String l;
401                 while ((l = reader.readLine()) != null) {
402                     result.append(l);
403                 }
404                 return result.toString();
405             }
406         } catch (IOException e) {
407             return null;
408         } finally {
409             if (con != null) {
410                 con.disconnect();
411             }
412         }
413     }
414
415     /*
416      * Methods used by {@link LxServerHandler}
417      */
418
419     /**
420      * Sends an action to a Loxone Miniserver's control.
421      *
422      * @param id identifier of the control
423      * @param operation identifier of the operation
424      * @throws IOException when communication error with Miniserver occurs
425      */
426     void sendAction(LxUuid id, String operation) throws IOException {
427         String command = CMD_ACTION + id.getOriginalString() + "/" + operation;
428         logger.debug("[{}] Sending command {}", debugId, command);
429         LxResponse response = sendCmdWithResp(command, true, true);
430         if (response == null) {
431             throw new IOException("Error sending command " + command);
432         }
433         if (!response.isResponseOk()) {
434             if (response.getResponseCode() == LxErrorCode.USER_UNAUTHORIZED) {
435                 // we don't support per-control passwords, because the controls should have been filtered to remove
436                 // secured ones, it is an unexpected situation to receive this error code, but generally we can continue
437                 // operation
438                 logger.warn("[{}] User not authorised to operate on control {}", debugId, id);
439             } else {
440                 throw new IOException("Received response is not ok to command " + command);
441             }
442         }
443     }
444
445     /**
446      * Send keep-alive message to the Miniserver
447      */
448     void sendKeepAlive() {
449         sendCmdNoResp(CMD_KEEPALIVE, false);
450     }
451
452     /**
453      * Sets Miniserver firmware version, if known.
454      *
455      * @param fwVersion Miniserver firmware version
456      */
457     void setFwVersion(String fwVersion) {
458         this.fwVersion = fwVersion;
459     }
460
461     /**
462      * Start a timer to wait for a Miniserver response to an action sent from the binding.
463      * When timer expires, connection is removed and server error is reported. Further connection attempt can be made
464      * later by the upper layer.
465      * If a previous timer is running, it will be stopped before a new timer is started.
466      * The caller must take care of thread synchronization.
467      */
468     void startResponseTimeout() {
469         webSocketLock.lock();
470         try {
471             stopResponseTimeout();
472             timeout = SCHEDULER.schedule(this::responseTimeout, responseTimeout, TimeUnit.SECONDS);
473         } finally {
474             webSocketLock.unlock();
475         }
476     }
477
478     /**
479      * Disconnect websocket session - initiated from this end.
480      *
481      * @param code error code for disconnecting the websocket
482      * @param reason reason for disconnecting the websocket
483      */
484     void disconnect(LxErrorCode code, String reason) {
485         logger.trace("[{}] disconnect the websocket: {}, {}", debugId, code, reason);
486         // in case the disconnection happens from both connection ends, store and pass only the first reason
487         if (offlineCode == null) {
488             offlineCode = code;
489             offlineReason = reason;
490         }
491         stopResponseTimeout();
492         if (session != null) {
493             logger.debug("[{}] Closing session", debugId);
494             session.close(StatusCode.NORMAL, reason);
495             logger.debug("[{}] Session closed", debugId);
496         } else {
497             logger.debug("[{}] Disconnecting websocket, but no session, reason : {}", debugId, reason);
498             thingHandler.setOffline(LxErrorCode.COMMUNICATION_ERROR, reason);
499         }
500     }
501
502     /*
503      * Private methods
504      */
505
506     /**
507      * Stops scheduled timeout waiting for a Miniserver response
508      * The caller must take care of thread synchronization.
509      */
510     private void stopResponseTimeout() {
511         webSocketLock.lock();
512         try {
513             logger.trace("[{}] stopping response timeout", debugId);
514             if (timeout != null) {
515                 timeout.cancel(true);
516                 timeout = null;
517             }
518         } finally {
519             webSocketLock.unlock();
520         }
521     }
522
523     /**
524      * Sends a command to the Miniserver and encrypts it if command can be encrypted and encryption is available.
525      * The request is asynchronous and no response is expected (but it can arrive). It can be used to send commands
526      * from the websocket thread or commands for which the responses are not following the standard format defined
527      * in {@link LxResponse}.
528      * If the caller expects the non-standard response it should manage its reception and the response timeout.
529      *
530      * @param command command to send to the Miniserver
531      * @param encrypt true if command can be encrypted (does not mean it will)
532      * @return true if command was sent (no information if it was received by the remote end)
533      */
534     private boolean sendCmdNoResp(String command, boolean encrypt) {
535         webSocketLock.lock();
536         try {
537             if (session != null) {
538                 String encrypted;
539                 if (encrypt) {
540                     encrypted = security.encrypt(command);
541                     logger.debug("[{}] Sending encrypted string: {}", debugId, command);
542                     logger.debug("[{}] Encrypted: {}", debugId, encrypted);
543                 } else {
544                     logger.debug("[{}] Sending unencrypted string: {}", debugId, command);
545                     encrypted = command;
546                 }
547                 try {
548                     session.getRemote().sendString(encrypted);
549                     return true;
550                 } catch (IOException e) {
551                     logger.debug("[{}] Error sending command: {}, {}", debugId, command, e.getMessage());
552                     return false;
553                 }
554             } else {
555                 logger.debug("[{}] NOT sending command: {}", debugId, command);
556                 return false;
557             }
558         } finally {
559             webSocketLock.unlock();
560         }
561     }
562
563     /**
564      * Process a Miniserver's response to a command. The response is in plain text format as received from the
565      * websocket, but is expected to follow the standard format defined in {@link LxResponse}.
566      * If there is a thread waiting for the response (on a synchronous command request), the thread will be
567      * released. Otherwise the response will be copied into the response object provided to the asynchronous
568      * requester when the command was sent.
569      * Only one requester is expected to wait for the response at a time - commands must be sent sequentially - a
570      * command can be sent only after a response to the previous command was received, whether it was sent
571      * synchronously or asynchronously.
572      * If the received message is encrypted, it will be decrypted before processing.
573      *
574      * @param message websocket message with the response
575      */
576     private void processResponse(String message) {
577         LxResponse resp = getResponse(message);
578         if (resp == null) {
579             return;
580         }
581         logger.debug("[{}] Response: {}", debugId, message.trim());
582         String control = resp.getCommand().trim();
583         control = security.decryptControl(control);
584         // for some reason the responses to some commands starting with jdev begin with dev, not jdev
585         // this seems to be a bug in the Miniserver
586         if (control.startsWith("dev/")) {
587             control = "j" + control;
588         }
589         responseLock.lock();
590         try {
591             if (awaitedResponse == null || awaitingCommand == null) {
592                 logger.warn("[{}] Received response, but awaiting none.", debugId);
593                 return;
594             }
595             if (!awaitingCommand.equals(control)) {
596                 logger.warn("[{}] Waiting for another response: {}", debugId, awaitingCommand);
597                 return;
598             }
599             awaitedResponse.subResponse = resp.subResponse;
600             if (syncRequest) {
601                 logger.debug("[{}] Releasing command sender with response: {}, {}", debugId, control,
602                         resp.getResponseCodeNumber());
603                 responseAvailable.signal();
604             } else {
605                 logger.debug("[{}] Reponse to asynchronous request: {}, {}", debugId, control,
606                         resp.getResponseCodeNumber());
607                 awaitedResponse = null;
608                 awaitingCommand = null;
609             }
610         } finally {
611             responseLock.unlock();
612         }
613     }
614
615     /**
616      * Perform actions after user authentication is successfully completed.
617      * This method sends a request to receive Miniserver configuration.
618      */
619     private void authenticated() {
620         logger.debug("[{}] Websocket authentication successfull.", debugId);
621         webSocketLock.lock();
622         try {
623             awaitingConfiguration = true;
624             if (sendCmdNoResp(CMD_GET_APP_CONFIG, false)) {
625                 startResponseTimeout();
626             } else {
627                 disconnect(LxErrorCode.INTERNAL_ERROR, "Error sending get config command.");
628             }
629         } finally {
630             webSocketLock.unlock();
631         }
632     }
633
634     /**
635      * Called when response timeout occurred.
636      */
637     private void responseTimeout() {
638         logger.debug("[{}] Miniserver response timeout", debugId);
639         disconnect(LxErrorCode.COMMUNICATION_ERROR, "Miniserver response timeout occured");
640     }
641 }