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.IOException;
16 import java.net.InetAddress;
18 import java.net.UnknownHostException;
19 import java.time.Duration;
20 import java.time.Instant;
21 import java.util.ArrayList;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
27 import java.util.concurrent.BlockingQueue;
28 import java.util.concurrent.LinkedBlockingQueue;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.atomic.AtomicBoolean;
31 import java.util.concurrent.atomic.AtomicInteger;
32 import java.util.concurrent.locks.Lock;
33 import java.util.concurrent.locks.ReentrantLock;
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;
63 import com.google.gson.Gson;
64 import com.google.gson.GsonBuilder;
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.
70 * @author Pawel Pieczul - Initial contribution
72 public class LxServerHandler extends BaseThingHandler implements LxServerHandlerApi {
74 private static final String SOCKET_URL = "/ws/rfc6455";
75 private static final String CMD_CFG_API = "jdev/cfg/api";
77 private static final Gson GSON;
79 private LxBindingConfiguration bindingConfig;
80 private InetAddress host;
82 // initial delay to initiate connection
83 private AtomicInteger reconnectDelay = new AtomicInteger();
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<>();
91 private LxWebSocket socket;
92 private WebSocketClient wsClient;
94 private int debugId = 0;
95 private Thread monitorThread;
96 private final Lock threadLock = new ReentrantLock();
97 private AtomicBoolean sessionActive = new AtomicBoolean(false);
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<>();
104 private LxDynamicStateDescriptionProvider dynamicStateDescriptionProvider;
105 private final Logger logger = LoggerFactory.getLogger(LxServerHandler.class);
106 private static AtomicInteger staticDebugId = new AtomicInteger(1);
108 static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
109 .singleton(LxBindingConstants.THING_TYPE_MINISERVER);
111 private QueuedThreadPool jettyThreadPool;
114 GsonBuilder builder = new GsonBuilder();
115 builder.registerTypeAdapter(LxUuid.class, LxUuid.DESERIALIZER);
116 builder.registerTypeAdapter(LxControl.class, LxControl.DESERIALIZER);
117 GSON = builder.create();
121 * Create {@link LxServerHandler} object
123 * @param thing Thing object that creates the handler
124 * @param provider state description provider service
126 public LxServerHandler(Thing thing, LxDynamicStateDescriptionProvider provider) {
128 logger.debug("[{}] Constructing thing object", debugId);
129 if (provider != null) {
130 dynamicStateDescriptionProvider = provider;
132 logger.warn("Dynamic state description provider is null");
137 * Methods from BaseThingHandler
141 public void handleCommand(ChannelUID channelUID, Command command) {
142 if (command instanceof RefreshType) {
143 updateChannelState(channelUID);
147 LxControl control = channels.get(channelUID);
148 if (control != null) {
149 control.handleCommand(channelUID, command);
151 logger.error("[{}] Received command {} for unknown control.", debugId, command);
153 } catch (IOException e) {
154 setOffline(LxErrorCode.COMMUNICATION_ERROR, e.getMessage());
159 public void channelLinked(ChannelUID channelUID) {
160 logger.debug("[{}] Channel linked: {}", debugId, channelUID.getAsString());
161 updateChannelState(channelUID);
165 public void initialize() {
168 debugId = staticDebugId.getAndIncrement();
170 logger.debug("[{}] Initializing thing instance", debugId);
171 bindingConfig = getConfig().as(LxBindingConfiguration.class);
173 this.host = InetAddress.getByName(bindingConfig.host);
174 } catch (UnknownHostException e) {
175 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unknown host");
178 reconnectDelay.set(bindingConfig.firstConDelay);
180 jettyThreadPool = new QueuedThreadPool();
181 jettyThreadPool.setName(LxServerHandler.class.getSimpleName() + "-" + debugId);
182 jettyThreadPool.setDaemon(true);
184 socket = new LxWebSocket(debugId, this, bindingConfig, host);
185 wsClient = new WebSocketClient();
186 wsClient.setExecutor(jettyThreadPool);
188 reconnectDelay.set(0);
190 if (monitorThread == null) {
191 monitorThread = new LxServerThread(debugId);
192 monitorThread.start();
200 public void dispose() {
201 logger.debug("[{}] Disposing of thing", debugId);
205 sessionActive.set(false);
206 stateUpdateQueue.clear();
207 thread = monitorThread;
208 if (monitorThread != null) {
209 monitorThread.interrupt();
210 monitorThread = null;
212 clearConfiguration();
216 if (thread != null) {
219 } catch (InterruptedException e) {
220 logger.warn("[{}] Waiting for thread termination interrupted.", debugId);
226 * Public methods that are called by {@link LxControl} child classes
232 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#sendAction(org.openhab.binding.loxone.internal.types.
233 * LxUuid, java.lang.String)
236 public void sendAction(LxUuid id, String operation) throws IOException {
237 socket.sendAction(id, operation);
244 * org.openhab.binding.loxone.internal.LxServerHandlerApi#addControl(org.openhab.binding.loxone.internal.controls.
248 public void addControl(LxControl control) {
249 addControlStructures(control);
250 addThingChannels(control.getChannelsWithSubcontrols(), false);
257 * org.openhab.binding.loxone.internal.LxServerHandlerApi#removeControl(org.openhab.binding.loxone.internal.controls
261 public void removeControl(LxControl control) {
262 logger.debug("[{}] Removing control: {}", debugId, control.getName());
263 control.getSubControls().values().forEach(subControl -> removeControl(subControl));
264 LxUuid controlUuid = control.getUuid();
265 control.getStates().values().forEach(state -> {
266 LxUuid stateUuid = state.getUuid();
267 Map<LxUuid, LxState> perUuid = states.get(stateUuid);
268 if (perUuid != null) {
269 perUuid.remove(controlUuid);
270 if (perUuid.isEmpty()) {
271 states.remove(stateUuid);
276 ThingBuilder builder = editThing();
277 control.getChannels().forEach(channel -> {
278 ChannelUID id = channel.getUID();
279 builder.withoutChannel(id);
280 dynamicStateDescriptionProvider.removeDescription(id);
283 updateThing(builder.build());
284 controls.remove(controlUuid);
290 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelState(org.openhab.core.thing.
291 * ChannelUID, org.openhab.core.types.State)
294 public void setChannelState(ChannelUID channelId, State state) {
295 updateState(channelId, state);
302 * org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelStateDescription(org.openhab.core.
303 * thing.ChannelUID, org.openhab.core.types.StateDescription)
306 public void setChannelStateDescription(ChannelUID channelId, StateDescription description) {
307 logger.debug("[{}] State description update for channel {}", debugId, channelId);
308 dynamicStateDescriptionProvider.setDescription(channelId, description);
312 * Public methods called by {@link LxWsSecurity} child classes.
318 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getSetting(java.lang.String)
321 public String getSetting(String name) {
322 Object value = getConfig().get(name);
323 return (value instanceof String) ? (String) value : null;
329 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setSettings(java.util.Map)
332 public void setSettings(Map<String, String> properties) {
333 Configuration config = getConfig();
334 properties.forEach((name, value) -> config.put(name, value));
335 updateConfiguration(config);
341 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getGson()
344 public Gson getGson() {
351 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getThingId()
354 public ThingUID getThingId() {
355 return getThing().getUID();
359 * Methods called by {@link LxWebSocket} class.
363 * Dispose of all objects created from the Miniserver configuration.
365 void clearConfiguration() {
369 dynamicStateDescriptionProvider.removeAllDescriptions();
373 * Sets a new configuration received from the Miniserver and creates all required channels.
375 * @param config Miniserver's configuration
377 void setMiniserverConfig(LxConfig config) {
378 logger.debug("[{}] Setting configuration from Miniserver", debugId);
380 if (config.msInfo == null) {
381 logger.warn("[{}] missing global configuration msInfo on Loxone", debugId);
382 config.msInfo = config.new LxServerInfo();
384 Thing thing = getThing();
385 LxServerInfo info = config.msInfo;
386 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_MINISERVER_NAME, buildName(info.msName));
387 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_PROJECT_NAME, buildName(info.projectName));
388 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_CLOUD_ADDRESS, buildName(info.remoteUrl));
389 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_PHYSICAL_LOCATION, buildName(info.location));
390 thing.setProperty(Thing.PROPERTY_FIRMWARE_VERSION, buildName(info.swVersion));
391 thing.setProperty(Thing.PROPERTY_SERIAL_NUMBER, buildName(info.serialNr));
392 thing.setProperty(Thing.PROPERTY_MAC_ADDRESS, buildName(info.macAddress));
394 List<Channel> list = new ArrayList<>();
395 if (config.controls != null) {
396 logger.trace("[{}] creating control structures.", debugId);
397 config.controls.values().forEach(ctrl -> {
398 addControlStructures(ctrl);
399 list.addAll(ctrl.getChannelsWithSubcontrols());
402 logger.warn("[{}] no controls received in Miniserver configuration.", debugId);
404 addThingChannels(list, true);
405 updateStatus(ThingStatus.ONLINE);
409 * Set thing status to offline and start attempts to establish a new connection to the Miniserver after a delay
410 * depending of the reason for going offline.
412 * @param code error code
413 * @param reason reason for going offline
415 void setOffline(LxErrorCode code, String reason) {
416 logger.debug("[{}] set offline code={} reason={}", debugId, code, reason);
418 case TOO_MANY_FAILED_LOGIN_ATTEMPTS:
419 // assume credentials are wrong, do not re-attempt connections any time soon
420 // expect a new instance will have to be initialized with corrected configuration
421 reconnectDelay.set(60 * 60 * 24 * 7);
422 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
423 "Too many failed login attempts - stopped trying");
425 case USER_UNAUTHORIZED:
426 reconnectDelay.set(bindingConfig.userErrorDelay);
427 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
428 reason != null ? reason : "User authentication error (invalid user name or password)");
430 case USER_AUTHENTICATION_TIMEOUT:
431 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "User authentication timeout");
433 case COMMUNICATION_ERROR:
434 reconnectDelay.set(bindingConfig.comErrorDelay);
435 String text = "Error communicating with Miniserver";
436 if (reason != null) {
437 text += " (" + reason + ")";
439 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, text);
442 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
443 reason != null ? "Internal error (" + reason + ")" : "Internal error");
445 case WEBSOCKET_IDLE_TIMEOUT:
446 logger.warn("Idle timeout from Loxone Miniserver - adjust keepalive settings");
447 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Timeout due to no activity");
449 case ERROR_CODE_MISSING:
450 logger.warn("No error code available from the Miniserver");
451 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Unknown reason - error code missing");
454 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Unknown reason");
457 sessionActive.set(false);
461 * Put a new state update event to the queue for processing and signal thread to process it
463 * @param uuid state uuid (null indicates websocket session should be closed)
464 * @param value new state value
466 void queueStateUpdate(LxUuid uuid, Object value) {
467 stateUpdateQueue.add(new LxStateUpdate(uuid, value));
471 * Update to the new value of a state received from Miniserver. This method will go through all instances of this
472 * state UUID and update their value, which will trigger corresponding control state update method in each control
473 * that has this state.
475 * @param update Miniserver's update event
477 private void updateStateValue(LxStateUpdate update) {
478 Map<LxUuid, LxState> perStateUuid = states.get(update.getUuid());
479 if (perStateUuid != null) {
480 perStateUuid.forEach((controlUuid, state) -> {
481 state.setStateValue(update.getValue());
487 * Add a new control, its states, subcontrols and channels to the handler structures.
488 * Handler maintains maps of all controls (main controls + subcontrols), all channels for all controls and all
489 * states to match received openHAB commands and state updates from the Miniserver. States also contain links to
490 * possibly multiple control objects, as many controls can share the same state with the same state uuid.
491 * To create channels, {@link LxServerHandler#addThingChannels} method should be called separately. This allows
492 * creation of all channels for all controls with a single thing update.
494 * @param control a created control object to be added
496 private void addControlStructures(LxControl control) {
497 LxUuid uuid = control.getUuid();
498 logger.debug("[{}] Adding control to handler: {}, {}", debugId, uuid, control.getName());
499 control.getStates().values().forEach(state -> {
500 Map<LxUuid, LxState> perUuid = states.get(state.getUuid());
501 if (perUuid == null) {
502 perUuid = new HashMap<>();
503 states.put(state.getUuid(), perUuid);
505 perUuid.put(uuid, state);
507 controls.put(control.getUuid(), control);
508 control.getChannels().forEach(channel -> channels.put(channel.getUID(), control));
509 control.getSubControls().values().forEach(subControl -> addControlStructures(subControl));
513 * Adds channels to the thing, to make them available to the framework and user.
514 * This method will sort the channels according to their label.
515 * It is expected that input list contains no duplicate channel IDs.
517 * @param newChannels a list of channels to add to the thing
518 * @param purge if true, old channels will be removed, otherwise merged
520 private void addThingChannels(List<Channel> newChannels, boolean purge) {
521 List<Channel> channels = newChannels;
523 channels.addAll(getThing().getChannels());
525 channels.sort((c1, c2) -> {
526 String label1 = c1.getLabel();
527 String label2 = c2.getLabel();
528 if (label1 != null && label2 != null) {
529 return label1.compareTo(label2);
530 } else if (label1 == null && label2 != null) {
532 } else if (label1 != null && label2 == null) {
538 ThingBuilder builder = editThing();
539 builder.withChannels(channels);
540 updateThing(builder.build());
544 * Connect the websocket.
545 * Attempts to connect to the websocket on a remote Miniserver. If a connection is established, a
546 * {@link LxWebSocket#onConnect} method will be called from a parallel websocket thread.
548 * @return true if connection request initiated correctly, false if not
550 private boolean connect() {
551 logger.debug("[{}] connect() websocket", debugId);
553 * Try to read CfgApi structure from the miniserver. It contains serial number and firmware version. If it can't
554 * be read this is not a fatal issue, we will assume most recent version running.
556 String message = socket.httpGet(CMD_CFG_API);
557 if (message != null) {
558 LxResponse resp = socket.getResponse(message);
560 socket.setFwVersion(GSON.fromJson(resp.getValueAsString(), LxResponse.LxResponseCfgApi.class).version);
563 logger.debug("[{}] Http get failed for API config request.", debugId);
569 // Following the PR github.com/eclipse/smarthome/pull/6636
570 // without this zero timeout, jetty will wait 30 seconds for stopping the client to eventually fail
571 // with the timeout it is immediate and all threads end correctly
572 jettyThreadPool.setStopTimeout(0);
573 URI target = new URI("ws://" + host.getHostAddress() + ":" + bindingConfig.port + SOCKET_URL);
574 ClientUpgradeRequest request = new ClientUpgradeRequest();
575 request.setSubProtocols("remotecontrol");
577 socket.startResponseTimeout();
578 logger.debug("[{}] Connecting to server : {} ", debugId, target);
579 wsClient.connect(socket, target, request);
581 } catch (Exception e) {
582 logger.debug("[{}] Error starting websocket client: {}", debugId, e.getMessage());
585 } catch (Exception e2) {
586 logger.debug("[{}] Error stopping websocket client: {}", debugId, e2.getMessage());
597 * Disconnect websocket session - initiated from this end.
599 * @param code error code for disconnecting the websocket
600 * @param reason reason for disconnecting the websocket
602 private void disconnect(LxErrorCode code, String reason) {
603 logger.debug("[{}] disconnect the websocket: {}, {}", debugId, code, reason);
604 socket.disconnect(code, reason);
606 logger.debug("[{}] client stop", debugId);
608 logger.debug("[{}] client stopped", debugId);
609 } catch (Exception e) {
610 logger.debug("[{}] Exception disconnecting the websocket: ", e.getMessage());
615 * Thread that maintains connection to the Miniserver.
616 * It will periodically attempt to connect and if failed, wait a configured amount of time.
617 * If connection succeeds, it will sleep until the session is terminated. Then it will wait and try to reconnect
620 * @author Pawel Pieczul - initial contribution
623 private class LxServerThread extends Thread {
624 private int debugId = 0;
625 private long elapsed = 0;
626 private Instant lastKeepAlive;
628 LxServerThread(int id) {
634 logger.debug("[{}] Thread starting", debugId);
636 while (!isInterrupted()) {
637 sessionActive.set(connectSession());
638 processStateUpdates();
640 } catch (InterruptedException e) {
641 logger.debug("[{}] Thread interrupted", debugId);
643 disconnect(LxErrorCode.OK, "Thing is going down.");
644 logger.debug("[{}] Thread ending", debugId);
647 private boolean connectSession() throws InterruptedException {
648 int delay = reconnectDelay.get();
650 logger.debug("[{}] Delaying connect request by {} seconds.", debugId, reconnectDelay);
651 TimeUnit.SECONDS.sleep(delay);
653 logger.debug("[{}] Server connecting to websocket", debugId);
655 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR,
656 "Failed to connect to Miniserver's WebSocket");
657 reconnectDelay.set(bindingConfig.connectErrDelay);
660 lastKeepAlive = Instant.now();
664 private void processStateUpdates() throws InterruptedException {
665 while (sessionActive.get()) {
666 logger.debug("[{}] Sleeping for {} seconds.", debugId, bindingConfig.keepAlivePeriod - elapsed);
667 LxStateUpdate update = stateUpdateQueue.poll(bindingConfig.keepAlivePeriod - elapsed, TimeUnit.SECONDS);
668 elapsed = Duration.between(lastKeepAlive, Instant.now()).getSeconds();
669 if (update == null || elapsed >= bindingConfig.keepAlivePeriod) {
673 if (update != null) {
674 updateStateValue(update);
679 private void sendKeepAlive() {
680 socket.sendKeepAlive();
681 lastKeepAlive = Instant.now();
687 * Updates the thing status to offline, if it is not already offline. This will preserve he first reason of going
688 * offline in case there were multiple reasons.
690 * @param code error code
691 * @param reason reason for going offline
693 private void updateStatusToOffline(ThingStatusDetail code, String reason) {
694 ThingStatus status = getThing().getStatus();
695 if (status == ThingStatus.OFFLINE) {
696 logger.debug("[{}] received offline request with code {}, but thing already offline.", debugId, code);
698 updateStatus(ThingStatus.OFFLINE, code, reason);
703 * Updates an actual state of a channel.
704 * Determines control for the channel and retrieves the state from the control.
706 * @param channelId channel ID to update its state
708 private void updateChannelState(ChannelUID channelId) {
709 LxControl control = channels.get(channelId);
710 if (control != null) {
711 State state = control.getChannelState(channelId);
713 updateState(channelId, state);
716 logger.error("[{}] Received state update request for unknown control (channelId={}).", debugId, channelId);
721 * Check and convert null string to empty string.
723 * @param name string to check
724 * @return string guaranteed to be not null
726 private String buildName(String name) {