2 * Copyright (c) 2010-2020 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.ConcurrentLinkedQueue;
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.Condition;
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 final Lock queueUpdatedLock = new ReentrantLock();
98 private final Condition queueUpdated = queueUpdatedLock.newCondition();
99 private AtomicBoolean sessionActive = new AtomicBoolean(false);
102 private final Map<LxUuid, LxControl> controls = new HashMap<>();
103 private final Map<ChannelUID, LxControl> channels = new HashMap<>();
104 private final ConcurrentLinkedQueue<LxStateUpdate> stateUpdateQueue = new ConcurrentLinkedQueue<>();
106 private LxDynamicStateDescriptionProvider dynamicStateDescriptionProvider;
107 private final Logger logger = LoggerFactory.getLogger(LxServerHandler.class);
108 private static AtomicInteger staticDebugId = new AtomicInteger(1);
110 static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
111 .singleton(LxBindingConstants.THING_TYPE_MINISERVER);
113 private QueuedThreadPool jettyThreadPool;
116 GsonBuilder builder = new GsonBuilder();
117 builder.registerTypeAdapter(LxUuid.class, LxUuid.DESERIALIZER);
118 builder.registerTypeAdapter(LxControl.class, LxControl.DESERIALIZER);
119 GSON = builder.create();
123 * Create {@link LxServerHandler} object
125 * @param thing Thing object that creates the handler
126 * @param provider state description provider service
128 public LxServerHandler(Thing thing, LxDynamicStateDescriptionProvider provider) {
130 logger.debug("[{}] Constructing thing object", debugId);
131 if (provider != null) {
132 dynamicStateDescriptionProvider = provider;
134 logger.warn("Dynamic state description provider is null");
139 * Methods from BaseThingHandler
143 public void handleCommand(ChannelUID channelUID, Command command) {
144 if (command instanceof RefreshType) {
145 updateChannelState(channelUID);
149 LxControl control = channels.get(channelUID);
150 if (control != null) {
151 control.handleCommand(channelUID, command);
153 logger.error("[{}] Received command {} for unknown control.", debugId, command);
155 } catch (IOException e) {
156 setOffline(LxErrorCode.COMMUNICATION_ERROR, e.getMessage());
161 public void channelLinked(ChannelUID channelUID) {
162 logger.debug("[{}] Channel linked: {}", debugId, channelUID.getAsString());
163 updateChannelState(channelUID);
167 public void initialize() {
170 debugId = staticDebugId.getAndIncrement();
172 logger.debug("[{}] Initializing thing instance", debugId);
173 bindingConfig = getConfig().as(LxBindingConfiguration.class);
175 this.host = InetAddress.getByName(bindingConfig.host);
176 } catch (UnknownHostException e) {
177 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unknown host");
180 reconnectDelay.set(bindingConfig.firstConDelay);
182 jettyThreadPool = new QueuedThreadPool();
183 jettyThreadPool.setName(LxServerHandler.class.getSimpleName() + "-" + debugId);
184 jettyThreadPool.setDaemon(true);
186 socket = new LxWebSocket(debugId, this, bindingConfig, host);
187 wsClient = new WebSocketClient();
188 wsClient.setExecutor(jettyThreadPool);
190 reconnectDelay.set(0);
192 if (monitorThread == null) {
193 monitorThread = new LxServerThread(debugId);
194 monitorThread.start();
202 public void dispose() {
203 logger.debug("[{}] Disposing of thing", debugId);
207 sessionActive.set(false);
208 stateUpdateQueue.clear();
209 thread = monitorThread;
210 if (monitorThread != null) {
211 monitorThread.interrupt();
212 monitorThread = null;
214 clearConfiguration();
218 if (thread != null) {
221 } catch (InterruptedException e) {
222 logger.warn("[{}] Waiting for thread termination interrupted.", debugId);
228 * Public methods that are called by {@link LxControl} child classes
234 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#sendAction(org.openhab.binding.loxone.internal.types.
235 * LxUuid, java.lang.String)
238 public void sendAction(LxUuid id, String operation) throws IOException {
239 socket.sendAction(id, operation);
246 * org.openhab.binding.loxone.internal.LxServerHandlerApi#addControl(org.openhab.binding.loxone.internal.controls.
250 public void addControl(LxControl control) {
251 addControlStructures(control);
252 addThingChannels(control.getChannelsWithSubcontrols(), false);
259 * org.openhab.binding.loxone.internal.LxServerHandlerApi#removeControl(org.openhab.binding.loxone.internal.controls
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);
278 ThingBuilder builder = editThing();
279 control.getChannels().forEach(channel -> {
280 ChannelUID id = channel.getUID();
281 builder.withoutChannel(id);
282 dynamicStateDescriptionProvider.removeDescription(id);
285 updateThing(builder.build());
286 controls.remove(controlUuid);
292 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelState(org.openhab.core.thing.
293 * ChannelUID, org.openhab.core.types.State)
296 public void setChannelState(ChannelUID channelId, State state) {
297 updateState(channelId, state);
304 * org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelStateDescription(org.openhab.core.
305 * thing.ChannelUID, org.openhab.core.types.StateDescription)
308 public void setChannelStateDescription(ChannelUID channelId, StateDescription description) {
309 logger.debug("[{}] State description update for channel {}", debugId, channelId);
310 dynamicStateDescriptionProvider.setDescription(channelId, description);
314 * Public methods called by {@link LxWsSecurity} child classes.
320 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getSetting(java.lang.String)
323 public String getSetting(String name) {
324 Object value = getConfig().get(name);
325 return (value instanceof String) ? (String) value : null;
331 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setSettings(java.util.Map)
334 public void setSettings(Map<String, String> properties) {
335 Configuration config = getConfig();
336 properties.forEach((name, value) -> config.put(name, value));
337 updateConfiguration(config);
343 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getGson()
346 public Gson getGson() {
353 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getThingId()
356 public ThingUID getThingId() {
357 return getThing().getUID();
361 * Methods called by {@link LxWebSocket} class.
365 * Dispose of all objects created from the Miniserver configuration.
367 void clearConfiguration() {
371 dynamicStateDescriptionProvider.removeAllDescriptions();
375 * Sets a new configuration received from the Miniserver and creates all required channels.
377 * @param config Miniserver's configuration
379 void setMiniserverConfig(LxConfig config) {
380 logger.debug("[{}] Setting configuration from Miniserver", debugId);
382 if (config.msInfo == null) {
383 logger.warn("[{}] missing global configuration msInfo on Loxone", debugId);
384 config.msInfo = config.new LxServerInfo();
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));
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());
404 logger.warn("[{}] no controls received in Miniserver configuration.", debugId);
406 addThingChannels(list, true);
407 updateStatus(ThingStatus.ONLINE);
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.
414 * @param code error code
415 * @param reason reason for going offline
417 void setOffline(LxErrorCode code, String reason) {
418 logger.debug("[{}] set offline code={} reason={}", debugId, code, reason);
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");
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)");
432 case USER_AUTHENTICATION_TIMEOUT:
433 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "User authentication timeout");
435 case COMMUNICATION_ERROR:
436 reconnectDelay.set(bindingConfig.comErrorDelay);
437 String text = "Error communicating with Miniserver";
438 if (reason != null) {
439 text += " (" + reason + ")";
441 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, text);
444 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
445 reason != null ? "Internal error (" + reason + ")" : "Internal error");
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");
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");
456 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Unknown reason");
459 sessionActive.set(false);
463 * Put a new state update event to the queue for processing and signal thread to process it
465 * @param uuid state uuid (null indicates websocket session should be closed)
466 * @param value new state value
468 void queueStateUpdate(LxUuid uuid, Object value) {
469 stateUpdateQueue.add(new LxStateUpdate(uuid, value));
470 queueUpdatedLock.lock();
472 queueUpdated.signalAll();
474 queueUpdatedLock.unlock();
479 * Update to the new value of a state received from Miniserver. This method will go through all instances of this
480 * state UUID and update their value, which will trigger corresponding control state update method in each control
481 * that has this state.
483 * @param update Miniserver's update event
485 private void updateStateValue(LxStateUpdate update) {
486 Map<LxUuid, LxState> perStateUuid = states.get(update.getUuid());
487 if (perStateUuid != null) {
488 perStateUuid.forEach((controlUuid, state) -> {
489 state.setStateValue(update.getValue());
495 * Add a new control, its states, subcontrols and channels to the handler structures.
496 * Handler maintains maps of all controls (main controls + subcontrols), all channels for all controls and all
497 * states to match received openHAB commands and state updates from the Miniserver. States also contain links to
498 * possibly multiple control objects, as many controls can share the same state with the same state uuid.
499 * To create channels, {@link LxServerHandler#addThingChannels} method should be called separately. This allows
500 * creation of all channels for all controls with a single thing update.
502 * @param control a created control object to be added
504 private void addControlStructures(LxControl control) {
505 LxUuid uuid = control.getUuid();
506 logger.debug("[{}] Adding control to handler: {}, {}", debugId, uuid, control.getName());
507 control.getStates().values().forEach(state -> {
508 Map<LxUuid, LxState> perUuid = states.get(state.getUuid());
509 if (perUuid == null) {
510 perUuid = new HashMap<>();
511 states.put(state.getUuid(), perUuid);
513 perUuid.put(uuid, state);
515 controls.put(control.getUuid(), control);
516 control.getChannels().forEach(channel -> channels.put(channel.getUID(), control));
517 control.getSubControls().values().forEach(subControl -> addControlStructures(subControl));
521 * Adds channels to the thing, to make them available to the framework and user.
522 * This method will sort the channels according to their label.
523 * It is expected that input list contains no duplicate channel IDs.
525 * @param newChannels a list of channels to add to the thing
526 * @param purge if true, old channels will be removed, otherwise merged
528 private void addThingChannels(List<Channel> newChannels, boolean purge) {
529 List<Channel> channels = newChannels;
531 channels.addAll(getThing().getChannels());
533 channels.sort((c1, c2) -> {
534 String label = c1.getLabel();
535 return label == null ? 1 : label.compareTo(c2.getLabel());
537 ThingBuilder builder = editThing();
538 builder.withChannels(channels);
539 updateThing(builder.build());
543 * Connect the websocket.
544 * Attempts to connect to the websocket on a remote Miniserver. If a connection is established, a
545 * {@link LxWebSocket#onConnect} method will be called from a parallel websocket thread.
547 * @return true if connection request initiated correctly, false if not
549 private boolean connect() {
550 logger.debug("[{}] connect() websocket", debugId);
552 * Try to read CfgApi structure from the miniserver. It contains serial number and firmware version. If it can't
553 * be read this is not a fatal issue, we will assume most recent version running.
555 String message = socket.httpGet(CMD_CFG_API);
556 if (message != null) {
557 LxResponse resp = socket.getResponse(message);
559 socket.setFwVersion(GSON.fromJson(resp.getValueAsString(), LxResponse.LxResponseCfgApi.class).version);
562 logger.debug("[{}] Http get failed for API config request.", debugId);
568 // Following the PR github.com/eclipse/smarthome/pull/6636
569 // without this zero timeout, jetty will wait 30 seconds for stopping the client to eventually fail
570 // with the timeout it is immediate and all threads end correctly
571 jettyThreadPool.setStopTimeout(0);
572 URI target = new URI("ws://" + host.getHostAddress() + ":" + bindingConfig.port + SOCKET_URL);
573 ClientUpgradeRequest request = new ClientUpgradeRequest();
574 request.setSubProtocols("remotecontrol");
576 socket.startResponseTimeout();
577 logger.debug("[{}] Connecting to server : {} ", debugId, target);
578 wsClient.connect(socket, target, request);
580 } catch (Exception e) {
581 logger.debug("[{}] Error starting websocket client: {}", debugId, e.getMessage());
584 } catch (Exception e2) {
585 logger.debug("[{}] Error stopping websocket client: {}", debugId, e2.getMessage());
596 * Disconnect websocket session - initiated from this end.
598 * @param code error code for disconnecting the websocket
599 * @param reason reason for disconnecting the websocket
601 private void disconnect(LxErrorCode code, String reason) {
602 logger.debug("[{}] disconnect the websocket: {}, {}", debugId, code, reason);
603 socket.disconnect(code, reason);
605 logger.debug("[{}] client stop", debugId);
607 logger.debug("[{}] client stopped", debugId);
608 } catch (Exception e) {
609 logger.debug("[{}] Exception disconnecting the websocket: ", e.getMessage());
614 * Thread that maintains connection to the Miniserver.
615 * It will periodically attempt to connect and if failed, wait a configured amount of time.
616 * If connection succeeds, it will sleep until the session is terminated. Then it will wait and try to reconnect
619 * @author Pawel Pieczul - initial contribution
622 private class LxServerThread extends Thread {
623 private int debugId = 0;
624 private long elapsed = 0;
625 private Instant lastKeepAlive;
627 LxServerThread(int id) {
633 logger.debug("[{}] Thread starting", debugId);
635 while (!isInterrupted()) {
636 sessionActive.set(connectSession());
637 processStateUpdates();
639 } catch (InterruptedException e) {
640 logger.debug("[{}] Thread interrupted", debugId);
642 disconnect(LxErrorCode.OK, "Thing is going down.");
643 logger.debug("[{}] Thread ending", debugId);
646 private boolean connectSession() throws InterruptedException {
647 int delay = reconnectDelay.get();
649 logger.debug("[{}] Delaying connect request by {} seconds.", debugId, reconnectDelay);
650 TimeUnit.SECONDS.sleep(delay);
652 logger.debug("[{}] Server connecting to websocket", debugId);
654 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR,
655 "Failed to connect to Miniserver's WebSocket");
656 reconnectDelay.set(bindingConfig.connectErrDelay);
659 lastKeepAlive = Instant.now();
663 private void processStateUpdates() throws InterruptedException {
664 while (sessionActive.get()) {
665 logger.debug("[{}] Sleeping for {} seconds.", debugId, bindingConfig.keepAlivePeriod - elapsed);
666 queueUpdatedLock.lock();
668 if (!queueUpdated.await(bindingConfig.keepAlivePeriod - elapsed, TimeUnit.SECONDS)) {
673 queueUpdatedLock.unlock();
675 elapsed = Duration.between(lastKeepAlive, Instant.now()).getSeconds();
676 if (elapsed >= bindingConfig.keepAlivePeriod) {
679 LxStateUpdate update;
680 while ((update = stateUpdateQueue.poll()) != null && sessionActive.get()) {
681 updateStateValue(update);
686 private void sendKeepAlive() {
687 socket.sendKeepAlive();
688 lastKeepAlive = Instant.now();
694 * Updates the thing status to offline, if it is not already offline. This will preserve he first reason of going
695 * offline in case there were multiple reasons.
697 * @param code error code
698 * @param reason reason for going offline
700 private void updateStatusToOffline(ThingStatusDetail code, String reason) {
701 ThingStatus status = getThing().getStatus();
702 if (status == ThingStatus.OFFLINE) {
703 logger.debug("[{}] received offline request with code {}, but thing already offline.", debugId, code);
705 updateStatus(ThingStatus.OFFLINE, code, reason);
710 * Updates an actual state of a channel.
711 * Determines control for the channel and retrieves the state from the control.
713 * @param channelId channel ID to update its state
715 private void updateChannelState(ChannelUID channelId) {
716 LxControl control = channels.get(channelId);
717 if (control != null) {
718 State state = control.getChannelState(channelId);
720 updateState(channelId, state);
723 logger.error("[{}] Received state update request for unknown control (channelId={}).", debugId, channelId);
728 * Check and convert null string to empty string.
730 * @param name string to check
731 * @return string guaranteed to be not null
733 private String buildName(String name) {