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