]> git.basschouten.com Git - openhab-addons.git/blob
1829c190c585f99ad8bf8a2aa62014c3864a4c00
[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.IOException;
16 import java.net.InetAddress;
17 import java.net.URI;
18 import java.net.UnknownHostException;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.util.ArrayList;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Set;
26 import java.util.concurrent.BlockingQueue;
27 import java.util.concurrent.LinkedBlockingQueue;
28 import java.util.concurrent.TimeUnit;
29 import java.util.concurrent.atomic.AtomicBoolean;
30 import java.util.concurrent.atomic.AtomicInteger;
31 import java.util.concurrent.locks.Lock;
32 import java.util.concurrent.locks.ReentrantLock;
33
34 import org.eclipse.jetty.util.ssl.SslContextFactory;
35 import org.eclipse.jetty.util.thread.QueuedThreadPool;
36 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
37 import org.eclipse.jetty.websocket.client.WebSocketClient;
38 import org.openhab.binding.loxone.internal.controls.LxControl;
39 import org.openhab.binding.loxone.internal.types.LxConfig;
40 import org.openhab.binding.loxone.internal.types.LxConfig.LxServerInfo;
41 import org.openhab.binding.loxone.internal.types.LxErrorCode;
42 import org.openhab.binding.loxone.internal.types.LxResponse;
43 import org.openhab.binding.loxone.internal.types.LxState;
44 import org.openhab.binding.loxone.internal.types.LxStateUpdate;
45 import org.openhab.binding.loxone.internal.types.LxUuid;
46 import org.openhab.core.config.core.Configuration;
47 import org.openhab.core.thing.Channel;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.thing.ThingTypeUID;
53 import org.openhab.core.thing.ThingUID;
54 import org.openhab.core.thing.binding.BaseThingHandler;
55 import org.openhab.core.thing.binding.builder.ThingBuilder;
56 import org.openhab.core.types.Command;
57 import org.openhab.core.types.RefreshType;
58 import org.openhab.core.types.State;
59 import org.openhab.core.types.StateDescription;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
62
63 import com.google.gson.Gson;
64 import com.google.gson.GsonBuilder;
65
66 /**
67  * Representation of a Loxone Miniserver. It is an openHAB {@link Thing}, which is used to communicate with
68  * objects (controls) configured in the Miniserver over channels.
69  *
70  * @author Pawel Pieczul - Initial contribution
71  */
72 public class LxServerHandler extends BaseThingHandler implements LxServerHandlerApi {
73
74     private static final String SOCKET_URL = "/ws/rfc6455";
75     private static final String CMD_CFG_API = "jdev/cfg/apiKey";
76
77     private static final Gson GSON;
78
79     private LxBindingConfiguration bindingConfig;
80     private InetAddress host;
81
82     // initial delay to initiate connection
83     private AtomicInteger reconnectDelay = new AtomicInteger();
84
85     // Map of state UUID to a map of control UUID and state objects
86     // State with a unique UUID can be configured in many controls and each control can even have a different name of
87     // the state. It must be ensured that updates received for this state UUID are passed to all controls that have this
88     // state UUID configured.
89     private Map<LxUuid, Map<LxUuid, LxState>> states = new HashMap<>();
90
91     private LxWebSocket socket;
92     private WebSocketClient wsClient;
93
94     private int debugId = 0;
95     private Thread monitorThread;
96     private final Lock threadLock = new ReentrantLock();
97     private AtomicBoolean sessionActive = new AtomicBoolean(false);
98
99     // Data structures
100     private final Map<LxUuid, LxControl> controls = new HashMap<>();
101     private final Map<ChannelUID, LxControl> channels = new HashMap<>();
102     private final BlockingQueue<LxStateUpdate> stateUpdateQueue = new LinkedBlockingQueue<>();
103
104     private LxDynamicStateDescriptionProvider dynamicStateDescriptionProvider;
105     private final Logger logger = LoggerFactory.getLogger(LxServerHandler.class);
106     private static AtomicInteger staticDebugId = new AtomicInteger(1);
107
108     static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(LxBindingConstants.THING_TYPE_MINISERVER);
109
110     private QueuedThreadPool jettyThreadPool;
111
112     static {
113         GsonBuilder builder = new GsonBuilder();
114         builder.registerTypeAdapter(LxUuid.class, LxUuid.DESERIALIZER);
115         builder.registerTypeAdapter(LxControl.class, LxControl.DESERIALIZER);
116         GSON = builder.create();
117     }
118
119     /**
120      * Create {@link LxServerHandler} object
121      *
122      * @param thing Thing object that creates the handler
123      * @param provider state description provider service
124      */
125     public LxServerHandler(Thing thing, LxDynamicStateDescriptionProvider provider) {
126         super(thing);
127         logger.debug("[{}] Constructing thing object", debugId);
128         if (provider != null) {
129             dynamicStateDescriptionProvider = provider;
130         } else {
131             logger.warn("Dynamic state description provider is null");
132         }
133     }
134
135     /*
136      * Methods from BaseThingHandler
137      */
138
139     @Override
140     public void handleCommand(ChannelUID channelUID, Command command) {
141         logger.debug("[{}] Handle command: channelUID={}, command={}", debugId, channelUID, command);
142         if (command instanceof RefreshType) {
143             updateChannelState(channelUID);
144             return;
145         }
146         try {
147             LxControl control = channels.get(channelUID);
148             if (control != null) {
149                 logger.debug("[{}] Dispatching command to control UUID={}, name={}", debugId, control.getUuid(),
150                         control.getName());
151                 control.handleCommand(channelUID, command);
152             } else {
153                 logger.error("[{}] Received command {} for unknown control.", debugId, command);
154             }
155         } catch (IOException e) {
156             setOffline(LxErrorCode.COMMUNICATION_ERROR, e.getMessage());
157         }
158     }
159
160     @Override
161     public void channelLinked(ChannelUID channelUID) {
162         logger.debug("[{}] Channel linked: {}", debugId, channelUID.getAsString());
163         updateChannelState(channelUID);
164     }
165
166     @Override
167     public void initialize() {
168         threadLock.lock();
169         try {
170             debugId = staticDebugId.getAndIncrement();
171
172             logger.debug("[{}] Initializing thing instance", debugId);
173             bindingConfig = getConfig().as(LxBindingConfiguration.class);
174             try {
175                 this.host = InetAddress.getByName(bindingConfig.host);
176             } catch (UnknownHostException e) {
177                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unknown host");
178                 return;
179             }
180             reconnectDelay.set(bindingConfig.firstConDelay);
181
182             jettyThreadPool = new QueuedThreadPool();
183             jettyThreadPool.setName(LxServerHandler.class.getSimpleName() + "-" + debugId);
184             jettyThreadPool.setDaemon(true);
185
186             socket = new LxWebSocket(debugId, this, bindingConfig, host);
187             wsClient = new WebSocketClient(new SslContextFactory.Client(true));
188             wsClient.setExecutor(jettyThreadPool);
189             if (debugId > 1) {
190                 reconnectDelay.set(0);
191             }
192             if (monitorThread == null) {
193                 monitorThread = new LxServerThread(debugId);
194                 monitorThread.start();
195             }
196         } finally {
197             threadLock.unlock();
198         }
199     }
200
201     @Override
202     public void dispose() {
203         logger.debug("[{}] Disposing of thing", debugId);
204         Thread thread;
205         threadLock.lock();
206         try {
207             sessionActive.set(false);
208             stateUpdateQueue.clear();
209             thread = monitorThread;
210             if (monitorThread != null) {
211                 monitorThread.interrupt();
212                 monitorThread = null;
213             }
214             clearConfiguration();
215         } finally {
216             threadLock.unlock();
217         }
218         if (thread != null) {
219             try {
220                 thread.join(5000);
221             } catch (InterruptedException e) {
222                 logger.warn("[{}] Waiting for thread termination interrupted.", debugId);
223             }
224         }
225     }
226
227     /*
228      * Public methods that are called by {@link LxControl} child classes
229      */
230
231     /*
232      * (non-Javadoc)
233      *
234      * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#sendAction(org.openhab.binding.loxone.internal.types.
235      * LxUuid, java.lang.String)
236      */
237     @Override
238     public void sendAction(LxUuid id, String operation) throws IOException {
239         socket.sendAction(id, operation);
240     }
241
242     /*
243      * (non-Javadoc)
244      *
245      * @see
246      * org.openhab.binding.loxone.internal.LxServerHandlerApi#addControl(org.openhab.binding.loxone.internal.controls.
247      * LxControl)
248      */
249     @Override
250     public void addControl(LxControl control) {
251         addControlStructures(control);
252         addThingChannels(control.getChannelsWithSubcontrols(), false);
253     }
254
255     /*
256      * (non-Javadoc)
257      *
258      * @see
259      * org.openhab.binding.loxone.internal.LxServerHandlerApi#removeControl(org.openhab.binding.loxone.internal.controls
260      * .LxControl)
261      */
262     @Override
263     public void removeControl(LxControl control) {
264         logger.debug("[{}] Removing control: {}", debugId, control.getName());
265         control.getSubControls().values().forEach(subControl -> removeControl(subControl));
266         LxUuid controlUuid = control.getUuid();
267         control.getStates().values().forEach(state -> {
268             LxUuid stateUuid = state.getUuid();
269             Map<LxUuid, LxState> perUuid = states.get(stateUuid);
270             if (perUuid != null) {
271                 perUuid.remove(controlUuid);
272                 if (perUuid.isEmpty()) {
273                     states.remove(stateUuid);
274                 }
275             }
276         });
277
278         ThingBuilder builder = editThing();
279         control.getChannels().forEach(channel -> {
280             ChannelUID id = channel.getUID();
281             builder.withoutChannel(id);
282             dynamicStateDescriptionProvider.removeDescription(id);
283             channels.remove(id);
284         });
285         updateThing(builder.build());
286         controls.remove(controlUuid);
287     }
288
289     /*
290      * (non-Javadoc)
291      *
292      * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelState(org.openhab.core.thing.
293      * ChannelUID, org.openhab.core.types.State)
294      */
295     @Override
296     public void setChannelState(ChannelUID channelId, State state) {
297         updateState(channelId, state);
298     }
299
300     /*
301      * (non-Javadoc)
302      *
303      * @see
304      * org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelStateDescription(org.openhab.core.
305      * thing.ChannelUID, org.openhab.core.types.StateDescription)
306      */
307     @Override
308     public void setChannelStateDescription(ChannelUID channelId, StateDescription description) {
309         logger.debug("[{}] State description update for channel {}", debugId, channelId);
310         dynamicStateDescriptionProvider.setDescription(channelId, description);
311     }
312
313     /*
314      * Public methods called by {@link LxWsSecurity} child classes.
315      */
316
317     /*
318      * (non-Javadoc)
319      *
320      * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getSetting(java.lang.String)
321      */
322     @Override
323     public String getSetting(String name) {
324         Object value = getConfig().get(name);
325         return (value instanceof String s) ? s : null;
326     }
327
328     /*
329      * (non-Javadoc)
330      *
331      * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setSettings(java.util.Map)
332      */
333     @Override
334     public void setSettings(Map<String, String> properties) {
335         Configuration config = getConfig();
336         properties.forEach((name, value) -> config.put(name, value));
337         updateConfiguration(config);
338     }
339
340     /*
341      * (non-Javadoc)
342      *
343      * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getGson()
344      */
345     @Override
346     public Gson getGson() {
347         return GSON;
348     }
349
350     /*
351      * (non-Javadoc)
352      *
353      * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getThingId()
354      */
355     @Override
356     public ThingUID getThingId() {
357         return getThing().getUID();
358     }
359
360     /*
361      * Methods called by {@link LxWebSocket} class.
362      */
363
364     /**
365      * Dispose of all objects created from the Miniserver configuration.
366      */
367     void clearConfiguration() {
368         controls.clear();
369         channels.clear();
370         states.clear();
371         dynamicStateDescriptionProvider.removeAllDescriptions();
372     }
373
374     /**
375      * Sets a new configuration received from the Miniserver and creates all required channels.
376      *
377      * @param config Miniserver's configuration
378      */
379     void setMiniserverConfig(LxConfig config) {
380         logger.debug("[{}] Setting configuration from Miniserver", debugId);
381
382         if (config.msInfo == null) {
383             logger.warn("[{}] missing global configuration msInfo on Loxone", debugId);
384             config.msInfo = config.new LxServerInfo();
385         }
386         Thing thing = getThing();
387         LxServerInfo info = config.msInfo;
388         thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_MINISERVER_NAME, buildName(info.msName));
389         thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_PROJECT_NAME, buildName(info.projectName));
390         thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_CLOUD_ADDRESS, buildName(info.remoteUrl));
391         thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_PHYSICAL_LOCATION, buildName(info.location));
392         thing.setProperty(Thing.PROPERTY_FIRMWARE_VERSION, buildName(info.swVersion));
393         thing.setProperty(Thing.PROPERTY_SERIAL_NUMBER, buildName(info.serialNr));
394         thing.setProperty(Thing.PROPERTY_MAC_ADDRESS, buildName(info.macAddress));
395
396         List<Channel> list = new ArrayList<>();
397         if (config.controls != null) {
398             logger.trace("[{}] creating control structures.", debugId);
399             config.controls.values().forEach(ctrl -> {
400                 addControlStructures(ctrl);
401                 list.addAll(ctrl.getChannelsWithSubcontrols());
402             });
403         } else {
404             logger.warn("[{}] no controls received in Miniserver configuration.", debugId);
405         }
406         addThingChannels(list, true);
407         updateStatus(ThingStatus.ONLINE);
408     }
409
410     /**
411      * Set thing status to offline and start attempts to establish a new connection to the Miniserver after a delay
412      * depending of the reason for going offline.
413      *
414      * @param code error code
415      * @param reason reason for going offline
416      */
417     void setOffline(LxErrorCode code, String reason) {
418         logger.debug("[{}] set offline code={} reason={}", debugId, code, reason);
419         switch (code) {
420             case TOO_MANY_FAILED_LOGIN_ATTEMPTS:
421                 // assume credentials are wrong, do not re-attempt connections any time soon
422                 // expect a new instance will have to be initialized with corrected configuration
423                 reconnectDelay.set(60 * 60 * 24 * 7);
424                 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
425                         "Too many failed login attempts - stopped trying");
426                 break;
427             case USER_UNAUTHORIZED:
428                 reconnectDelay.set(bindingConfig.userErrorDelay);
429                 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
430                         reason != null ? reason : "User authentication error (invalid user name or password)");
431                 break;
432             case USER_AUTHENTICATION_TIMEOUT:
433                 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "User authentication timeout");
434                 break;
435             case COMMUNICATION_ERROR:
436                 reconnectDelay.set(bindingConfig.comErrorDelay);
437                 String text = "Error communicating with Miniserver";
438                 if (reason != null) {
439                     text += " (" + reason + ")";
440                 }
441                 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, text);
442                 break;
443             case INTERNAL_ERROR:
444                 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
445                         reason != null ? "Internal error (" + reason + ")" : "Internal error");
446                 break;
447             case WEBSOCKET_IDLE_TIMEOUT:
448                 logger.warn("Idle timeout from Loxone Miniserver - adjust keepalive settings");
449                 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Timeout due to no activity");
450                 break;
451             case ERROR_CODE_MISSING:
452                 logger.warn("No error code available from the Miniserver");
453                 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Unknown reason - error code missing");
454                 break;
455             default:
456                 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Unknown reason");
457                 break;
458         }
459         sessionActive.set(false);
460     }
461
462     /**
463      * Put a new state update event to the queue for processing and signal thread to process it
464      *
465      * @param uuid state uuid (null indicates websocket session should be closed)
466      * @param value new state value
467      */
468     void queueStateUpdate(LxUuid uuid, Object value) {
469         stateUpdateQueue.add(new LxStateUpdate(uuid, value));
470     }
471
472     /**
473      * Update to the new value of a state received from Miniserver. This method will go through all instances of this
474      * state UUID and update their value, which will trigger corresponding control state update method in each control
475      * that has this state.
476      *
477      * @param update Miniserver's update event
478      */
479     private void updateStateValue(LxStateUpdate update) {
480         Map<LxUuid, LxState> perStateUuid = states.get(update.getUuid());
481         if (perStateUuid != null) {
482             perStateUuid.forEach((controlUuid, state) -> {
483                 logger.debug("[{}] State update (UUID={}, value={}) dispatched to control UUID={}, state name={}",
484                         debugId, update.getUuid(), update.getValue(), controlUuid, state.getName());
485
486                 state.setStateValue(update.getValue());
487             });
488             if (perStateUuid.isEmpty()) {
489                 logger.debug("[{}] State update UUID={} has empty controls table", debugId, update.getUuid());
490             }
491         } else {
492             logger.debug("[{}] State update UUID={} has no controls table", debugId, update.getUuid());
493         }
494     }
495
496     /**
497      * Add a new control, its states, subcontrols and channels to the handler structures.
498      * Handler maintains maps of all controls (main controls + subcontrols), all channels for all controls and all
499      * states to match received openHAB commands and state updates from the Miniserver. States also contain links to
500      * possibly multiple control objects, as many controls can share the same state with the same state uuid.
501      * To create channels, {@link LxServerHandler#addThingChannels} method should be called separately. This allows
502      * creation of all channels for all controls with a single thing update.
503      *
504      * @param control a created control object to be added
505      */
506     private void addControlStructures(LxControl control) {
507         LxUuid uuid = control.getUuid();
508         logger.debug("[{}] Adding control to handler: {}, {}", debugId, uuid, control.getName());
509         control.getStates().values().forEach(state -> {
510             Map<LxUuid, LxState> perUuid = states.get(state.getUuid());
511             if (perUuid == null) {
512                 perUuid = new HashMap<>();
513                 states.put(state.getUuid(), perUuid);
514             }
515             perUuid.put(uuid, state);
516         });
517         controls.put(control.getUuid(), control);
518         control.getChannels().forEach(channel -> channels.put(channel.getUID(), control));
519         control.getSubControls().values().forEach(subControl -> addControlStructures(subControl));
520     }
521
522     /**
523      * Adds channels to the thing, to make them available to the framework and user.
524      * This method will sort the channels according to their label.
525      * It is expected that input list contains no duplicate channel IDs.
526      *
527      * @param newChannels a list of channels to add to the thing
528      * @param purge if true, old channels will be removed, otherwise merged
529      */
530     private void addThingChannels(List<Channel> newChannels, boolean purge) {
531         List<Channel> channels = newChannels;
532         if (!purge) {
533             channels.addAll(getThing().getChannels());
534         }
535         channels.sort((c1, c2) -> {
536             String label1 = c1.getLabel();
537             String label2 = c2.getLabel();
538             if (label1 != null && label2 != null) {
539                 return label1.compareTo(label2);
540             } else if (label1 == null && label2 != null) {
541                 return 1;
542             } else if (label1 != null && label2 == null) {
543                 return -1;
544             } else {
545                 return 0;
546             }
547         });
548         ThingBuilder builder = editThing();
549         builder.withChannels(channels);
550         updateThing(builder.build());
551     }
552
553     /**
554      * Connect the websocket.
555      * Attempts to connect to the websocket on a remote Miniserver. If a connection is established, a
556      * {@link LxWebSocket#onConnect} method will be called from a parallel websocket thread.
557      *
558      * @return true if connection request initiated correctly, false if not
559      */
560     private boolean connect() {
561         logger.debug("[{}] connect() websocket", debugId);
562         /*
563          * Try to read CfgApi structure from the miniserver. It contains serial number and firmware version. If it can't
564          * be read this is not a fatal issue, we will assume most recent version running.
565          */
566         boolean httpsCapable = false;
567         String message = socket.httpGet(CMD_CFG_API);
568         if (message != null) {
569             LxResponse resp = socket.getResponse(message);
570             if (resp != null) {
571                 LxResponse.LxResponseCfgApi apiResp = GSON.fromJson(resp.getValueAsString(),
572                         LxResponse.LxResponseCfgApi.class);
573                 if (apiResp != null) {
574                     socket.setFwVersion(apiResp.version);
575                     httpsCapable = apiResp.httpsStatus != null && apiResp.httpsStatus == 1;
576                 }
577             }
578         } else {
579             logger.debug("[{}] Http get failed for API config request.", debugId);
580         }
581
582         switch (bindingConfig.webSocketType) {
583             case 0:
584                 // keep automatically determined option
585                 break;
586             case 1:
587                 logger.debug("[{}] Forcing HTTPS websocket connection.", debugId);
588                 httpsCapable = true;
589                 break;
590             case 2:
591                 logger.debug("[{}] Forcing HTTP websocket connection.", debugId);
592                 httpsCapable = false;
593                 break;
594         }
595
596         try {
597             wsClient.start();
598
599             // Following the PR github.com/eclipse/smarthome/pull/6636
600             // without this zero timeout, jetty will wait 30 seconds for stopping the client to eventually fail
601             // with the timeout it is immediate and all threads end correctly
602             jettyThreadPool.setStopTimeout(0);
603             URI target;
604             if (httpsCapable) {
605                 target = new URI("wss://" + host.getHostAddress() + ":" + bindingConfig.httpsPort + SOCKET_URL);
606                 socket.setHttps(true);
607             } else {
608                 target = new URI("ws://" + host.getHostAddress() + ":" + bindingConfig.port + SOCKET_URL);
609                 socket.setHttps(false);
610             }
611             ClientUpgradeRequest request = new ClientUpgradeRequest();
612             request.setSubProtocols("remotecontrol");
613
614             socket.startResponseTimeout();
615             logger.debug("[{}] Connecting to server : {} ", debugId, target);
616             wsClient.connect(socket, target, request);
617             return true;
618         } catch (Exception e) {
619             logger.debug("[{}] Error starting websocket client: {}", debugId, e.getMessage());
620             try {
621                 wsClient.stop();
622             } catch (Exception e2) {
623                 logger.debug("[{}] Error stopping websocket client: {}", debugId, e2.getMessage());
624             }
625             return false;
626         }
627     }
628
629     /*
630      * Private methods
631      */
632
633     /**
634      * Disconnect websocket session - initiated from this end.
635      *
636      * @param code error code for disconnecting the websocket
637      * @param reason reason for disconnecting the websocket
638      */
639     private void disconnect(LxErrorCode code, String reason) {
640         logger.debug("[{}] disconnect the websocket: {}, {}", debugId, code, reason);
641         socket.disconnect(code, reason);
642         try {
643             logger.debug("[{}] client stop", debugId);
644             wsClient.stop();
645             logger.debug("[{}] client stopped", debugId);
646         } catch (Exception e) {
647             logger.debug("[{}] Exception disconnecting the websocket: ", e.getMessage());
648         }
649     }
650
651     /**
652      * Thread that maintains connection to the Miniserver.
653      * It will periodically attempt to connect and if failed, wait a configured amount of time.
654      * If connection succeeds, it will sleep until the session is terminated. Then it will wait and try to reconnect
655      * again.
656      *
657      * @author Pawel Pieczul - initial contribution
658      *
659      */
660     private class LxServerThread extends Thread {
661         private int debugId = 0;
662         private long elapsed = 0;
663         private Instant lastKeepAlive;
664
665         LxServerThread(int id) {
666             debugId = id;
667         }
668
669         @Override
670         public void run() {
671             logger.debug("[{}] Thread starting", debugId);
672             try {
673                 while (!isInterrupted()) {
674                     sessionActive.set(connectSession());
675                     processStateUpdates();
676                 }
677             } catch (InterruptedException e) {
678                 logger.debug("[{}] Thread interrupted", debugId);
679             }
680             disconnect(LxErrorCode.OK, "Thing is going down.");
681             logger.debug("[{}] Thread ending", debugId);
682         }
683
684         private boolean connectSession() throws InterruptedException {
685             int delay = reconnectDelay.get();
686             if (delay > 0) {
687                 logger.debug("[{}] Delaying connect request by {} seconds.", debugId, reconnectDelay);
688                 TimeUnit.SECONDS.sleep(delay);
689             }
690             logger.debug("[{}] Server connecting to websocket", debugId);
691             if (!connect()) {
692                 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR,
693                         "Failed to connect to Miniserver's WebSocket");
694                 reconnectDelay.set(bindingConfig.connectErrDelay);
695                 return false;
696             }
697             lastKeepAlive = Instant.now();
698             return true;
699         }
700
701         private void processStateUpdates() throws InterruptedException {
702             while (sessionActive.get()) {
703                 logger.debug("[{}] Sleeping for {} seconds.", debugId, bindingConfig.keepAlivePeriod - elapsed);
704                 LxStateUpdate update = stateUpdateQueue.poll(bindingConfig.keepAlivePeriod - elapsed, TimeUnit.SECONDS);
705                 elapsed = Duration.between(lastKeepAlive, Instant.now()).getSeconds();
706                 if (update == null || elapsed >= bindingConfig.keepAlivePeriod) {
707                     sendKeepAlive();
708                     elapsed = 0;
709                 }
710                 if (update != null) {
711                     updateStateValue(update);
712                 }
713             }
714         }
715
716         private void sendKeepAlive() {
717             socket.sendKeepAlive();
718             lastKeepAlive = Instant.now();
719             elapsed = 0;
720         }
721     }
722
723     /**
724      * Updates the thing status to offline, if it is not already offline. This will preserve he first reason of going
725      * offline in case there were multiple reasons.
726      *
727      * @param code error code
728      * @param reason reason for going offline
729      */
730     private void updateStatusToOffline(ThingStatusDetail code, String reason) {
731         ThingStatus status = getThing().getStatus();
732         if (status == ThingStatus.OFFLINE) {
733             logger.debug("[{}] received offline request with code {}, but thing already offline.", debugId, code);
734         } else {
735             updateStatus(ThingStatus.OFFLINE, code, reason);
736         }
737     }
738
739     /**
740      * Updates an actual state of a channel.
741      * Determines control for the channel and retrieves the state from the control.
742      *
743      * @param channelId channel ID to update its state
744      */
745     private void updateChannelState(ChannelUID channelId) {
746         LxControl control = channels.get(channelId);
747         if (control != null) {
748             State state = control.getChannelState(channelId);
749             if (state != null) {
750                 updateState(channelId, state);
751             }
752         } else {
753             logger.error("[{}] Received state update request for unknown control (channelId={}).", debugId, channelId);
754         }
755     }
756
757     /**
758      * Check and convert null string to empty string.
759      *
760      * @param name string to check
761      * @return string guaranteed to be not null
762      */
763     private String buildName(String name) {
764         if (name == null) {
765             return "";
766         }
767         return name;
768     }
769 }