2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.loxone.internal;
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;
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;
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;
50 import com.google.gson.Gson;
51 import com.google.gson.JsonParseException;
54 * Implementation of jetty websocket client
56 * @author Pawel Pieczul - initial contribution
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";
66 private final int debugId;
67 private final Gson gson;
68 private final LxServerHandler thingHandler;
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;
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;
92 private LxErrorCode offlineCode;
93 private String offlineReason;
95 private static final ScheduledExecutorService SCHEDULER = ThreadPoolManager
96 .getScheduledPool(LxWebSocket.class.getSimpleName());
97 private final Logger logger = LoggerFactory.getLogger(LxWebSocket.class);
100 * Create websocket object.
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
107 LxWebSocket(int debugId, LxServerHandler thingHandler, LxBindingConfiguration cfg, InetAddress host) {
108 this.debugId = debugId;
109 this.thingHandler = thingHandler;
111 this.port = cfg.port;
112 this.user = cfg.user;
113 this.password = cfg.password;
114 this.gson = thingHandler.getGson();
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;
121 if (cfg.maxBinMsgSize > 0 && cfg.maxBinMsgSize != maxBinMsgSize) {
122 logger.debug("[{}] Changing maxBinMsgSize to {}", debugId, cfg.maxBinMsgSize);
123 maxBinMsgSize = cfg.maxBinMsgSize;
125 if (cfg.maxTextMsgSize > 0 && cfg.maxTextMsgSize != maxTextMsgSize) {
126 logger.debug("[{}] Changing maxTextMsgSize to {}", debugId, cfg.maxTextMsgSize);
127 maxTextMsgSize = cfg.maxTextMsgSize;
132 * Jetty websocket methods
136 public void onConnect(Session session) {
137 webSocketLock.lock();
140 offlineReason = null;
141 WebSocketPolicy policy = session.getPolicy();
142 policy.setMaxBinaryMessageSize(maxBinMsgSize * 1024);
143 policy.setMaxTextMessageSize(maxTextMsgSize * 1024);
145 logger.debug("[{}] Websocket connected (maxBinMsgSize={}, maxTextMsgSize={})", debugId,
146 policy.getMaxBinaryMessageSize(), policy.getMaxTextMessageSize());
147 this.session = session;
149 security = LxWsSecurity.create(securityType, fwVersion, debugId, thingHandler, this, user, password);
150 security.authenticate((result, details) -> {
151 if (result == LxErrorCode.OK) {
154 disconnect(result, details);
158 webSocketLock.unlock();
163 public void onClose(int statusCode, String reason) {
165 LxErrorCode codeToPass;
166 webSocketLock.lock();
168 logger.debug("[{}] Websocket connection closed with code {} reason : {}", debugId, statusCode, reason);
169 if (security != 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;
180 codeToPass = LxErrorCode.getErrorCode(statusCode);
181 reasonToPass = reason;
184 webSocketLock.unlock();
187 // Release any requester waiting for message response
190 if (awaitedResponse != null) {
191 awaitedResponse.subResponse = null;
193 responseAvailable.signalAll();
195 responseLock.unlock();
197 thingHandler.setOffline(codeToPass, reasonToPass);
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.
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);
216 webSocketLock.lock();
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
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:
230 // other header types have no data and next message will be header again
237 switch (header.getType()) {
238 case EVENT_TABLE_OF_VALUE_STATES:
239 stopResponseTimeout();
241 Double value = ByteBuffer.wrap(data, offset + 16, 8).order(ByteOrder.LITTLE_ENDIAN)
243 thingHandler.queueStateUpdate(new LxUuid(data, offset), value);
248 case EVENT_TABLE_OF_TEXT_STATES:
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);
259 case KEEPALIVE_RESPONSE:
264 // header will be next
267 } catch (IndexOutOfBoundsException e) {
268 logger.debug("[{}] malformed binary message received, discarded", debugId);
270 webSocketLock.unlock();
275 public void onMessage(String msg) {
276 webSocketLock.lock();
278 if (logger.isTraceEnabled()) {
280 if (trace.length() > 100) {
281 trace = msg.substring(0, 100);
283 logger.trace("[{}] received message: {}", debugId, trace);
285 if (!awaitingConfiguration) {
286 processResponse(msg);
289 awaitingConfiguration = false;
290 stopResponseTimeout();
291 thingHandler.clearConfiguration();
293 LxConfig config = gson.fromJson(msg, LxConfig.class);
294 config.finalize(thingHandler);
296 thingHandler.setMiniserverConfig(config);
298 if (sendCmdWithResp(CMD_ENABLE_UPDATES, false, false) == null) {
299 disconnect(LxErrorCode.COMMUNICATION_ERROR, "Failed to enable state updates.");
302 webSocketLock.unlock();
307 * Public methods, called by {@link LxControl} and {@link LxWsSecurity} child classes
311 * Parse received message into a response structure. Check basic correctness of the response.
313 * @param msg received response message
314 * @return parsed response message
316 public LxResponse getResponse(String msg) {
318 LxResponse resp = gson.fromJson(msg, LxResponse.class);
319 if (!resp.isResponseOk()) {
320 logger.debug("[{}] Miniserver response is not ok: {}", debugId, msg);
324 } catch (JsonParseException e) {
325 logger.debug("[{}] Miniserver response JSON parsing error: {}, {}", debugId, msg, e.getMessage());
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
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
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
351 public LxResponse sendCmdWithResp(String command, boolean sync, boolean encrypt) {
354 if (awaitedResponse != null || awaitingCommand != null) {
355 logger.warn("[{}] Command not sent, previous command not finished: {}", debugId, command);
358 if (!sendCmdNoResp(command, encrypt)) {
361 LxResponse resp = new LxResponse();
362 awaitingCommand = command;
363 awaitedResponse = resp;
366 if (!responseAvailable.await(responseTimeout, TimeUnit.SECONDS)) {
367 awaitedResponse = null;
368 awaitingCommand = null;
372 awaitedResponse = null;
373 awaitingCommand = null;
376 } catch (InterruptedException e) {
377 logger.debug("[{}] Interrupted waiting for response: {}", debugId, command);
378 awaitedResponse = null;
379 awaitingCommand = null;
382 responseLock.unlock();
387 * Send a HTTP GET request and return server's response.
389 * @param request request content
390 * @return response received
392 public String httpGet(String request) {
393 HttpURLConnection con = null;
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()))) {
401 while ((l = reader.readLine()) != null) {
404 return result.toString();
406 } catch (IOException e) {
416 * Methods used by {@link LxServerHandler}
420 * Sends an action to a Loxone Miniserver's control.
422 * @param id identifier of the control
423 * @param operation identifier of the operation
424 * @throws IOException when communication error with Miniserver occurs
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);
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
438 logger.warn("[{}] User not authorised to operate on control {}", debugId, id);
440 throw new IOException("Received response is not ok to command " + command);
446 * Send keep-alive message to the Miniserver
448 void sendKeepAlive() {
449 sendCmdNoResp(CMD_KEEPALIVE, false);
453 * Sets Miniserver firmware version, if known.
455 * @param fwVersion Miniserver firmware version
457 void setFwVersion(String fwVersion) {
458 this.fwVersion = fwVersion;
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.
468 void startResponseTimeout() {
469 webSocketLock.lock();
471 stopResponseTimeout();
472 timeout = SCHEDULER.schedule(this::responseTimeout, responseTimeout, TimeUnit.SECONDS);
474 webSocketLock.unlock();
479 * Disconnect websocket session - initiated from this end.
481 * @param code error code for disconnecting the websocket
482 * @param reason reason for disconnecting the websocket
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) {
489 offlineReason = reason;
491 stopResponseTimeout();
492 if (session != null) {
493 logger.debug("[{}] Closing session", debugId);
494 session.close(StatusCode.NORMAL, reason);
495 logger.debug("[{}] Session closed", debugId);
497 logger.debug("[{}] Disconnecting websocket, but no session, reason : {}", debugId, reason);
498 thingHandler.setOffline(LxErrorCode.COMMUNICATION_ERROR, reason);
507 * Stops scheduled timeout waiting for a Miniserver response
508 * The caller must take care of thread synchronization.
510 private void stopResponseTimeout() {
511 webSocketLock.lock();
513 logger.trace("[{}] stopping response timeout", debugId);
514 if (timeout != null) {
515 timeout.cancel(true);
519 webSocketLock.unlock();
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.
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)
534 private boolean sendCmdNoResp(String command, boolean encrypt) {
535 webSocketLock.lock();
537 if (session != null) {
540 encrypted = security.encrypt(command);
541 logger.debug("[{}] Sending encrypted string: {}", debugId, command);
542 logger.debug("[{}] Encrypted: {}", debugId, encrypted);
544 logger.debug("[{}] Sending unencrypted string: {}", debugId, command);
548 session.getRemote().sendString(encrypted);
550 } catch (IOException e) {
551 logger.debug("[{}] Error sending command: {}, {}", debugId, command, e.getMessage());
555 logger.debug("[{}] NOT sending command: {}", debugId, command);
559 webSocketLock.unlock();
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.
574 * @param message websocket message with the response
576 private void processResponse(String message) {
577 LxResponse resp = getResponse(message);
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;
591 if (awaitedResponse == null || awaitingCommand == null) {
592 logger.warn("[{}] Received response, but awaiting none.", debugId);
595 if (!awaitingCommand.equals(control)) {
596 logger.warn("[{}] Waiting for another response: {}", debugId, awaitingCommand);
599 awaitedResponse.subResponse = resp.subResponse;
601 logger.debug("[{}] Releasing command sender with response: {}, {}", debugId, control,
602 resp.getResponseCodeNumber());
603 responseAvailable.signal();
605 logger.debug("[{}] Reponse to asynchronous request: {}, {}", debugId, control,
606 resp.getResponseCodeNumber());
607 awaitedResponse = null;
608 awaitingCommand = null;
611 responseLock.unlock();
616 * Perform actions after user authentication is successfully completed.
617 * This method sends a request to receive Miniserver configuration.
619 private void authenticated() {
620 logger.debug("[{}] Websocket authentication successfull.", debugId);
621 webSocketLock.lock();
623 awaitingConfiguration = true;
624 if (sendCmdNoResp(CMD_GET_APP_CONFIG, false)) {
625 startResponseTimeout();
627 disconnect(LxErrorCode.INTERNAL_ERROR, "Error sending get config command.");
630 webSocketLock.unlock();
635 * Called when response timeout occurred.
637 private void responseTimeout() {
638 logger.debug("[{}] Miniserver response timeout", debugId);
639 disconnect(LxErrorCode.COMMUNICATION_ERROR, "Miniserver response timeout occured");