2 * Copyright (c) 2010-2023 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 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;
93 private LxErrorCode offlineCode;
94 private String offlineReason;
96 private static final ScheduledExecutorService SCHEDULER = ThreadPoolManager
97 .getScheduledPool(LxWebSocket.class.getSimpleName());
98 private final Logger logger = LoggerFactory.getLogger(LxWebSocket.class);
101 * Create websocket object.
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
108 LxWebSocket(int debugId, LxServerHandler thingHandler, LxBindingConfiguration cfg, InetAddress host) {
109 this.debugId = debugId;
110 this.thingHandler = thingHandler;
112 this.port = cfg.port;
113 this.user = cfg.user;
114 this.password = cfg.password;
115 this.gson = thingHandler.getGson();
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;
122 if (cfg.maxBinMsgSize > 0 && cfg.maxBinMsgSize != maxBinMsgSize) {
123 logger.debug("[{}] Changing maxBinMsgSize to {}", debugId, cfg.maxBinMsgSize);
124 maxBinMsgSize = cfg.maxBinMsgSize;
126 if (cfg.maxTextMsgSize > 0 && cfg.maxTextMsgSize != maxTextMsgSize) {
127 logger.debug("[{}] Changing maxTextMsgSize to {}", debugId, cfg.maxTextMsgSize);
128 maxTextMsgSize = cfg.maxTextMsgSize;
133 * Jetty websocket methods
137 public void onConnect(Session session) {
138 webSocketLock.lock();
141 offlineReason = null;
142 WebSocketPolicy policy = session.getPolicy();
143 policy.setMaxBinaryMessageSize(maxBinMsgSize * 1024);
144 policy.setMaxTextMessageSize(maxTextMsgSize * 1024);
146 logger.debug("[{}] Websocket connected (maxBinMsgSize={}, maxTextMsgSize={})", debugId,
147 policy.getMaxBinaryMessageSize(), policy.getMaxTextMessageSize());
148 this.session = session;
150 security = LxWsSecurity.create(securityType, fwVersion, debugId, thingHandler, this, user, password);
151 security.authenticate((result, details) -> {
152 if (result == LxErrorCode.OK) {
155 disconnect(result, details);
159 webSocketLock.unlock();
164 public void onClose(int statusCode, String reason) {
166 LxErrorCode codeToPass;
167 webSocketLock.lock();
169 logger.debug("[{}] Websocket connection closed with code {} reason : {}", debugId, statusCode, reason);
170 if (security != 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;
181 codeToPass = LxErrorCode.getErrorCode(statusCode);
182 reasonToPass = reason;
185 webSocketLock.unlock();
188 // Release any requester waiting for message response
191 if (awaitedResponse != null) {
192 awaitedResponse.subResponse = null;
194 responseAvailable.signalAll();
196 responseLock.unlock();
198 thingHandler.setOffline(codeToPass, reasonToPass);
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.
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);
217 webSocketLock.lock();
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
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:
231 // other header types have no data and next message will be header again
238 switch (header.getType()) {
239 case EVENT_TABLE_OF_VALUE_STATES:
240 stopResponseTimeout();
242 Double value = ByteBuffer.wrap(data, offset + 16, 8).order(ByteOrder.LITTLE_ENDIAN)
244 thingHandler.queueStateUpdate(new LxUuid(data, offset), value);
249 case EVENT_TABLE_OF_TEXT_STATES:
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);
260 case KEEPALIVE_RESPONSE:
265 // header will be next
268 } catch (IndexOutOfBoundsException e) {
269 logger.debug("[{}] malformed binary message received, discarded", debugId);
271 webSocketLock.unlock();
276 public void onMessage(String msg) {
277 webSocketLock.lock();
279 if (logger.isTraceEnabled()) {
281 if (trace.length() > 100) {
282 trace = msg.substring(0, 100);
284 logger.trace("[{}] received message: {}", debugId, trace);
286 if (!awaitingConfiguration) {
287 processResponse(msg);
290 awaitingConfiguration = false;
291 stopResponseTimeout();
292 thingHandler.clearConfiguration();
294 LxConfig config = gson.fromJson(msg, LxConfig.class);
295 config.finalize(thingHandler);
297 thingHandler.setMiniserverConfig(config);
299 if (sendCmdWithResp(CMD_ENABLE_UPDATES, false, false) == null) {
300 disconnect(LxErrorCode.COMMUNICATION_ERROR, "Failed to enable state updates.");
303 webSocketLock.unlock();
308 * Public methods, called by {@link LxControl} and {@link LxWsSecurity} child classes
312 * Parse received message into a response structure. Check basic correctness of the response.
314 * @param msg received response message
315 * @return parsed response message
317 public LxResponse getResponse(String msg) {
319 LxResponse resp = gson.fromJson(msg, LxResponse.class);
320 if (!resp.isResponseOk()) {
321 logger.debug("[{}] Miniserver response is not ok: {}", debugId, msg);
325 } catch (JsonParseException e) {
326 logger.debug("[{}] Miniserver response JSON parsing error: {}, {}", debugId, msg, e.getMessage());
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
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
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
352 public LxResponse sendCmdWithResp(String command, boolean sync, boolean encrypt) {
355 if (awaitedResponse != null || awaitingCommand != null) {
356 logger.warn("[{}] Command not sent, previous command not finished: {}", debugId, command);
359 if (!sendCmdNoResp(command, encrypt)) {
362 LxResponse resp = new LxResponse();
363 awaitingCommand = command;
364 awaitedResponse = resp;
367 if (!responseAvailable.await(responseTimeout, TimeUnit.SECONDS)) {
368 awaitedResponse = null;
369 awaitingCommand = null;
373 awaitedResponse = null;
374 awaitingCommand = null;
377 } catch (InterruptedException e) {
378 logger.debug("[{}] Interrupted waiting for response: {}", debugId, command);
379 awaitedResponse = null;
380 awaitingCommand = null;
383 responseLock.unlock();
388 * Send a HTTP GET request and return server's response.
390 * @param request request content
391 * @return response received
393 public String httpGet(String request) {
394 HttpURLConnection con = null;
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()))) {
402 while ((l = reader.readLine()) != null) {
405 return result.toString();
407 } catch (IOException e) {
417 * Methods used by {@link LxServerHandler}
421 * Sends an action to a Loxone Miniserver's control.
423 * @param id identifier of the control
424 * @param operation identifier of the operation
425 * @throws IOException when communication error with Miniserver occurs
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);
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
439 logger.warn("[{}] User not authorised to operate on control {}", debugId, id);
441 throw new IOException("Received response is not ok to command " + command);
447 * Send keep-alive message to the Miniserver
449 void sendKeepAlive() {
450 sendCmdNoResp(CMD_KEEPALIVE, false);
454 * Sets Miniserver firmware version, if known.
456 * @param fwVersion Miniserver firmware version
458 void setFwVersion(String fwVersion) {
459 logger.debug("[{}] Firmware version: {}", debugId, fwVersion);
460 this.fwVersion = fwVersion;
464 * Sets information if session is over HTTPS or HTTP protocol
466 * @param httpsSession true when HTTPS session
468 void setHttps(boolean httpsSession) {
469 logger.debug("[{}] HTTPS session: {}", debugId, httpsSession);
470 this.httpsSession = httpsSession;
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.
480 void startResponseTimeout() {
481 webSocketLock.lock();
483 stopResponseTimeout();
484 timeout = SCHEDULER.schedule(this::responseTimeout, responseTimeout, TimeUnit.SECONDS);
486 webSocketLock.unlock();
491 * Disconnect websocket session - initiated from this end.
493 * @param code error code for disconnecting the websocket
494 * @param reason reason for disconnecting the websocket
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) {
501 offlineReason = reason;
503 stopResponseTimeout();
504 if (session != null) {
505 logger.debug("[{}] Closing session", debugId);
506 session.close(StatusCode.NORMAL, reason);
507 logger.debug("[{}] Session closed", debugId);
509 logger.debug("[{}] Disconnecting websocket, but no session, reason : {}", debugId, reason);
510 thingHandler.setOffline(LxErrorCode.COMMUNICATION_ERROR, reason);
519 * Stops scheduled timeout waiting for a Miniserver response
520 * The caller must take care of thread synchronization.
522 private void stopResponseTimeout() {
523 webSocketLock.lock();
525 logger.trace("[{}] stopping response timeout", debugId);
526 if (timeout != null) {
527 timeout.cancel(true);
531 webSocketLock.unlock();
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.
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)
546 private boolean sendCmdNoResp(String command, boolean encrypt) {
547 webSocketLock.lock();
549 if (session != null) {
551 if (encrypt && !httpsSession) {
552 encrypted = security.encrypt(command);
553 logger.debug("[{}] Sending encrypted string: {}", debugId, command);
554 logger.debug("[{}] Encrypted: {}", debugId, encrypted);
556 logger.debug("[{}] Sending unencrypted string: {}", debugId, command);
560 session.getRemote().sendString(encrypted);
562 } catch (IOException e) {
563 logger.debug("[{}] Error sending command: {}, {}", debugId, command, e.getMessage());
567 logger.debug("[{}] NOT sending command: {}", debugId, command);
571 webSocketLock.unlock();
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.
586 * @param message websocket message with the response
588 private void processResponse(String message) {
589 LxResponse resp = getResponse(message);
593 logger.debug("[{}] Response: {}", debugId, message.trim());
594 String control = resp.getCommand().trim();
596 control = security.decryptControl(control);
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;
605 if (awaitedResponse == null || awaitingCommand == null) {
606 logger.warn("[{}] Received response, but awaiting none.", debugId);
609 if (!awaitingCommand.equals(control)) {
610 logger.warn("[{}] Waiting for another response: {}", debugId, awaitingCommand);
613 awaitedResponse.subResponse = resp.subResponse;
615 logger.debug("[{}] Releasing command sender with response: {}, {}", debugId, control,
616 resp.getResponseCodeNumber());
617 responseAvailable.signal();
619 logger.debug("[{}] Reponse to asynchronous request: {}, {}", debugId, control,
620 resp.getResponseCodeNumber());
621 awaitedResponse = null;
622 awaitingCommand = null;
625 responseLock.unlock();
630 * Perform actions after user authentication is successfully completed.
631 * This method sends a request to receive Miniserver configuration.
633 private void authenticated() {
634 logger.debug("[{}] Websocket authentication successful.", debugId);
635 webSocketLock.lock();
637 awaitingConfiguration = true;
638 if (sendCmdNoResp(CMD_GET_APP_CONFIG, false)) {
639 startResponseTimeout();
641 disconnect(LxErrorCode.INTERNAL_ERROR, "Error sending get config command.");
644 webSocketLock.unlock();
649 * Called when response timeout occurred.
651 private void responseTimeout() {
652 logger.debug("[{}] Miniserver response timeout", debugId);
653 disconnect(LxErrorCode.COMMUNICATION_ERROR, "Miniserver response timeout occured");