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.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.ssl.SslContextFactory;
36 import org.eclipse.jetty.util.thread.QueuedThreadPool;
37 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
38 import org.eclipse.jetty.websocket.client.WebSocketClient;
39 import org.openhab.binding.loxone.internal.controls.LxControl;
40 import org.openhab.binding.loxone.internal.types.LxConfig;
41 import org.openhab.binding.loxone.internal.types.LxConfig.LxServerInfo;
42 import org.openhab.binding.loxone.internal.types.LxErrorCode;
43 import org.openhab.binding.loxone.internal.types.LxResponse;
44 import org.openhab.binding.loxone.internal.types.LxState;
45 import org.openhab.binding.loxone.internal.types.LxStateUpdate;
46 import org.openhab.binding.loxone.internal.types.LxUuid;
47 import org.openhab.core.config.core.Configuration;
48 import org.openhab.core.thing.Channel;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.ThingTypeUID;
54 import org.openhab.core.thing.ThingUID;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.binding.builder.ThingBuilder;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.State;
60 import org.openhab.core.types.StateDescription;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
64 import com.google.gson.Gson;
65 import com.google.gson.GsonBuilder;
68 * Representation of a Loxone Miniserver. It is an openHAB {@link Thing}, which is used to communicate with
69 * objects (controls) configured in the Miniserver over channels.
71 * @author Pawel Pieczul - Initial contribution
73 public class LxServerHandler extends BaseThingHandler implements LxServerHandlerApi {
75 private static final String SOCKET_URL = "/ws/rfc6455";
76 private static final String CMD_CFG_API = "jdev/cfg/apiKey";
78 private static final Gson GSON;
80 private LxBindingConfiguration bindingConfig;
81 private InetAddress host;
83 // initial delay to initiate connection
84 private AtomicInteger reconnectDelay = new AtomicInteger();
86 // Map of state UUID to a map of control UUID and state objects
87 // State with a unique UUID can be configured in many controls and each control can even have a different name of
88 // the state. It must be ensured that updates received for this state UUID are passed to all controls that have this
89 // state UUID configured.
90 private Map<LxUuid, Map<LxUuid, LxState>> states = new HashMap<>();
92 private LxWebSocket socket;
93 private WebSocketClient wsClient;
95 private int debugId = 0;
96 private Thread monitorThread;
97 private final Lock threadLock = new ReentrantLock();
98 private AtomicBoolean sessionActive = new AtomicBoolean(false);
101 private final Map<LxUuid, LxControl> controls = new HashMap<>();
102 private final Map<ChannelUID, LxControl> channels = new HashMap<>();
103 private final BlockingQueue<LxStateUpdate> stateUpdateQueue = new LinkedBlockingQueue<>();
105 private LxDynamicStateDescriptionProvider dynamicStateDescriptionProvider;
106 private final Logger logger = LoggerFactory.getLogger(LxServerHandler.class);
107 private static AtomicInteger staticDebugId = new AtomicInteger(1);
109 static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
110 .singleton(LxBindingConstants.THING_TYPE_MINISERVER);
112 private QueuedThreadPool jettyThreadPool;
115 GsonBuilder builder = new GsonBuilder();
116 builder.registerTypeAdapter(LxUuid.class, LxUuid.DESERIALIZER);
117 builder.registerTypeAdapter(LxControl.class, LxControl.DESERIALIZER);
118 GSON = builder.create();
122 * Create {@link LxServerHandler} object
124 * @param thing Thing object that creates the handler
125 * @param provider state description provider service
127 public LxServerHandler(Thing thing, LxDynamicStateDescriptionProvider provider) {
129 logger.debug("[{}] Constructing thing object", debugId);
130 if (provider != null) {
131 dynamicStateDescriptionProvider = provider;
133 logger.warn("Dynamic state description provider is null");
138 * Methods from BaseThingHandler
142 public void handleCommand(ChannelUID channelUID, Command command) {
143 logger.debug("[{}] Handle command: channelUID={}, command={}", debugId, channelUID, command);
144 if (command instanceof RefreshType) {
145 updateChannelState(channelUID);
149 LxControl control = channels.get(channelUID);
150 if (control != null) {
151 logger.debug("[{}] Dispatching command to control UUID={}, name={}", debugId, control.getUuid(),
153 control.handleCommand(channelUID, command);
155 logger.error("[{}] Received command {} for unknown control.", debugId, command);
157 } catch (IOException e) {
158 setOffline(LxErrorCode.COMMUNICATION_ERROR, e.getMessage());
163 public void channelLinked(ChannelUID channelUID) {
164 logger.debug("[{}] Channel linked: {}", debugId, channelUID.getAsString());
165 updateChannelState(channelUID);
169 public void initialize() {
172 debugId = staticDebugId.getAndIncrement();
174 logger.debug("[{}] Initializing thing instance", debugId);
175 bindingConfig = getConfig().as(LxBindingConfiguration.class);
177 this.host = InetAddress.getByName(bindingConfig.host);
178 } catch (UnknownHostException e) {
179 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unknown host");
182 reconnectDelay.set(bindingConfig.firstConDelay);
184 jettyThreadPool = new QueuedThreadPool();
185 jettyThreadPool.setName(LxServerHandler.class.getSimpleName() + "-" + debugId);
186 jettyThreadPool.setDaemon(true);
188 socket = new LxWebSocket(debugId, this, bindingConfig, host);
189 wsClient = new WebSocketClient(new SslContextFactory.Client(true));
190 wsClient.setExecutor(jettyThreadPool);
192 reconnectDelay.set(0);
194 if (monitorThread == null) {
195 monitorThread = new LxServerThread(debugId);
196 monitorThread.start();
204 public void dispose() {
205 logger.debug("[{}] Disposing of thing", debugId);
209 sessionActive.set(false);
210 stateUpdateQueue.clear();
211 thread = monitorThread;
212 if (monitorThread != null) {
213 monitorThread.interrupt();
214 monitorThread = null;
216 clearConfiguration();
220 if (thread != null) {
223 } catch (InterruptedException e) {
224 logger.warn("[{}] Waiting for thread termination interrupted.", debugId);
230 * Public methods that are called by {@link LxControl} child classes
236 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#sendAction(org.openhab.binding.loxone.internal.types.
237 * LxUuid, java.lang.String)
240 public void sendAction(LxUuid id, String operation) throws IOException {
241 socket.sendAction(id, operation);
248 * org.openhab.binding.loxone.internal.LxServerHandlerApi#addControl(org.openhab.binding.loxone.internal.controls.
252 public void addControl(LxControl control) {
253 addControlStructures(control);
254 addThingChannels(control.getChannelsWithSubcontrols(), false);
261 * org.openhab.binding.loxone.internal.LxServerHandlerApi#removeControl(org.openhab.binding.loxone.internal.controls
265 public void removeControl(LxControl control) {
266 logger.debug("[{}] Removing control: {}", debugId, control.getName());
267 control.getSubControls().values().forEach(subControl -> removeControl(subControl));
268 LxUuid controlUuid = control.getUuid();
269 control.getStates().values().forEach(state -> {
270 LxUuid stateUuid = state.getUuid();
271 Map<LxUuid, LxState> perUuid = states.get(stateUuid);
272 if (perUuid != null) {
273 perUuid.remove(controlUuid);
274 if (perUuid.isEmpty()) {
275 states.remove(stateUuid);
280 ThingBuilder builder = editThing();
281 control.getChannels().forEach(channel -> {
282 ChannelUID id = channel.getUID();
283 builder.withoutChannel(id);
284 dynamicStateDescriptionProvider.removeDescription(id);
287 updateThing(builder.build());
288 controls.remove(controlUuid);
294 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelState(org.openhab.core.thing.
295 * ChannelUID, org.openhab.core.types.State)
298 public void setChannelState(ChannelUID channelId, State state) {
299 updateState(channelId, state);
306 * org.openhab.binding.loxone.internal.LxServerHandlerApi#setChannelStateDescription(org.openhab.core.
307 * thing.ChannelUID, org.openhab.core.types.StateDescription)
310 public void setChannelStateDescription(ChannelUID channelId, StateDescription description) {
311 logger.debug("[{}] State description update for channel {}", debugId, channelId);
312 dynamicStateDescriptionProvider.setDescription(channelId, description);
316 * Public methods called by {@link LxWsSecurity} child classes.
322 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getSetting(java.lang.String)
325 public String getSetting(String name) {
326 Object value = getConfig().get(name);
327 return (value instanceof String) ? (String) value : null;
333 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#setSettings(java.util.Map)
336 public void setSettings(Map<String, String> properties) {
337 Configuration config = getConfig();
338 properties.forEach((name, value) -> config.put(name, value));
339 updateConfiguration(config);
345 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getGson()
348 public Gson getGson() {
355 * @see org.openhab.binding.loxone.internal.LxServerHandlerApi#getThingId()
358 public ThingUID getThingId() {
359 return getThing().getUID();
363 * Methods called by {@link LxWebSocket} class.
367 * Dispose of all objects created from the Miniserver configuration.
369 void clearConfiguration() {
373 dynamicStateDescriptionProvider.removeAllDescriptions();
377 * Sets a new configuration received from the Miniserver and creates all required channels.
379 * @param config Miniserver's configuration
381 void setMiniserverConfig(LxConfig config) {
382 logger.debug("[{}] Setting configuration from Miniserver", debugId);
384 if (config.msInfo == null) {
385 logger.warn("[{}] missing global configuration msInfo on Loxone", debugId);
386 config.msInfo = config.new LxServerInfo();
388 Thing thing = getThing();
389 LxServerInfo info = config.msInfo;
390 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_MINISERVER_NAME, buildName(info.msName));
391 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_PROJECT_NAME, buildName(info.projectName));
392 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_CLOUD_ADDRESS, buildName(info.remoteUrl));
393 thing.setProperty(LxBindingConstants.MINISERVER_PROPERTY_PHYSICAL_LOCATION, buildName(info.location));
394 thing.setProperty(Thing.PROPERTY_FIRMWARE_VERSION, buildName(info.swVersion));
395 thing.setProperty(Thing.PROPERTY_SERIAL_NUMBER, buildName(info.serialNr));
396 thing.setProperty(Thing.PROPERTY_MAC_ADDRESS, buildName(info.macAddress));
398 List<Channel> list = new ArrayList<>();
399 if (config.controls != null) {
400 logger.trace("[{}] creating control structures.", debugId);
401 config.controls.values().forEach(ctrl -> {
402 addControlStructures(ctrl);
403 list.addAll(ctrl.getChannelsWithSubcontrols());
406 logger.warn("[{}] no controls received in Miniserver configuration.", debugId);
408 addThingChannels(list, true);
409 updateStatus(ThingStatus.ONLINE);
413 * Set thing status to offline and start attempts to establish a new connection to the Miniserver after a delay
414 * depending of the reason for going offline.
416 * @param code error code
417 * @param reason reason for going offline
419 void setOffline(LxErrorCode code, String reason) {
420 logger.debug("[{}] set offline code={} reason={}", debugId, code, reason);
422 case TOO_MANY_FAILED_LOGIN_ATTEMPTS:
423 // assume credentials are wrong, do not re-attempt connections any time soon
424 // expect a new instance will have to be initialized with corrected configuration
425 reconnectDelay.set(60 * 60 * 24 * 7);
426 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
427 "Too many failed login attempts - stopped trying");
429 case USER_UNAUTHORIZED:
430 reconnectDelay.set(bindingConfig.userErrorDelay);
431 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
432 reason != null ? reason : "User authentication error (invalid user name or password)");
434 case USER_AUTHENTICATION_TIMEOUT:
435 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "User authentication timeout");
437 case COMMUNICATION_ERROR:
438 reconnectDelay.set(bindingConfig.comErrorDelay);
439 String text = "Error communicating with Miniserver";
440 if (reason != null) {
441 text += " (" + reason + ")";
443 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, text);
446 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR,
447 reason != null ? "Internal error (" + reason + ")" : "Internal error");
449 case WEBSOCKET_IDLE_TIMEOUT:
450 logger.warn("Idle timeout from Loxone Miniserver - adjust keepalive settings");
451 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Timeout due to no activity");
453 case ERROR_CODE_MISSING:
454 logger.warn("No error code available from the Miniserver");
455 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR, "Unknown reason - error code missing");
458 updateStatusToOffline(ThingStatusDetail.CONFIGURATION_ERROR, "Unknown reason");
461 sessionActive.set(false);
465 * Put a new state update event to the queue for processing and signal thread to process it
467 * @param uuid state uuid (null indicates websocket session should be closed)
468 * @param value new state value
470 void queueStateUpdate(LxUuid uuid, Object value) {
471 stateUpdateQueue.add(new LxStateUpdate(uuid, value));
475 * Update to the new value of a state received from Miniserver. This method will go through all instances of this
476 * state UUID and update their value, which will trigger corresponding control state update method in each control
477 * that has this state.
479 * @param update Miniserver's update event
481 private void updateStateValue(LxStateUpdate update) {
482 Map<LxUuid, LxState> perStateUuid = states.get(update.getUuid());
483 if (perStateUuid != null) {
484 perStateUuid.forEach((controlUuid, state) -> {
485 logger.debug("[{}] State update (UUID={}, value={}) dispatched to control UUID={}, state name={}",
486 debugId, update.getUuid(), update.getValue(), controlUuid, state.getName());
488 state.setStateValue(update.getValue());
490 if (perStateUuid.isEmpty()) {
491 logger.debug("[{}] State update UUID={} has empty controls table", debugId, update.getUuid());
494 logger.debug("[{}] State update UUID={} has no controls table", debugId, update.getUuid());
499 * Add a new control, its states, subcontrols and channels to the handler structures.
500 * Handler maintains maps of all controls (main controls + subcontrols), all channels for all controls and all
501 * states to match received openHAB commands and state updates from the Miniserver. States also contain links to
502 * possibly multiple control objects, as many controls can share the same state with the same state uuid.
503 * To create channels, {@link LxServerHandler#addThingChannels} method should be called separately. This allows
504 * creation of all channels for all controls with a single thing update.
506 * @param control a created control object to be added
508 private void addControlStructures(LxControl control) {
509 LxUuid uuid = control.getUuid();
510 logger.debug("[{}] Adding control to handler: {}, {}", debugId, uuid, control.getName());
511 control.getStates().values().forEach(state -> {
512 Map<LxUuid, LxState> perUuid = states.get(state.getUuid());
513 if (perUuid == null) {
514 perUuid = new HashMap<>();
515 states.put(state.getUuid(), perUuid);
517 perUuid.put(uuid, state);
519 controls.put(control.getUuid(), control);
520 control.getChannels().forEach(channel -> channels.put(channel.getUID(), control));
521 control.getSubControls().values().forEach(subControl -> addControlStructures(subControl));
525 * Adds channels to the thing, to make them available to the framework and user.
526 * This method will sort the channels according to their label.
527 * It is expected that input list contains no duplicate channel IDs.
529 * @param newChannels a list of channels to add to the thing
530 * @param purge if true, old channels will be removed, otherwise merged
532 private void addThingChannels(List<Channel> newChannels, boolean purge) {
533 List<Channel> channels = newChannels;
535 channels.addAll(getThing().getChannels());
537 channels.sort((c1, c2) -> {
538 String label1 = c1.getLabel();
539 String label2 = c2.getLabel();
540 if (label1 != null && label2 != null) {
541 return label1.compareTo(label2);
542 } else if (label1 == null && label2 != null) {
544 } else if (label1 != null && label2 == null) {
550 ThingBuilder builder = editThing();
551 builder.withChannels(channels);
552 updateThing(builder.build());
556 * Connect the websocket.
557 * Attempts to connect to the websocket on a remote Miniserver. If a connection is established, a
558 * {@link LxWebSocket#onConnect} method will be called from a parallel websocket thread.
560 * @return true if connection request initiated correctly, false if not
562 private boolean connect() {
563 logger.debug("[{}] connect() websocket", debugId);
565 * Try to read CfgApi structure from the miniserver. It contains serial number and firmware version. If it can't
566 * be read this is not a fatal issue, we will assume most recent version running.
568 boolean httpsCapable = false;
569 String message = socket.httpGet(CMD_CFG_API);
570 if (message != null) {
571 LxResponse resp = socket.getResponse(message);
573 LxResponse.LxResponseCfgApi apiResp = GSON.fromJson(resp.getValueAsString(),
574 LxResponse.LxResponseCfgApi.class);
575 if (apiResp != null) {
576 socket.setFwVersion(apiResp.version);
577 httpsCapable = apiResp.httpsStatus != null && apiResp.httpsStatus == 1;
581 logger.debug("[{}] Http get failed for API config request.", debugId);
584 switch (bindingConfig.webSocketType) {
586 // keep automatically determined option
589 logger.debug("[{}] Forcing HTTPS websocket connection.", debugId);
593 logger.debug("[{}] Forcing HTTP websocket connection.", debugId);
594 httpsCapable = false;
601 // Following the PR github.com/eclipse/smarthome/pull/6636
602 // without this zero timeout, jetty will wait 30 seconds for stopping the client to eventually fail
603 // with the timeout it is immediate and all threads end correctly
604 jettyThreadPool.setStopTimeout(0);
607 target = new URI("wss://" + host.getHostAddress() + ":" + bindingConfig.httpsPort + SOCKET_URL);
608 socket.setHttps(true);
610 target = new URI("ws://" + host.getHostAddress() + ":" + bindingConfig.port + SOCKET_URL);
611 socket.setHttps(false);
613 ClientUpgradeRequest request = new ClientUpgradeRequest();
614 request.setSubProtocols("remotecontrol");
616 socket.startResponseTimeout();
617 logger.debug("[{}] Connecting to server : {} ", debugId, target);
618 wsClient.connect(socket, target, request);
620 } catch (Exception e) {
621 logger.debug("[{}] Error starting websocket client: {}", debugId, e.getMessage());
624 } catch (Exception e2) {
625 logger.debug("[{}] Error stopping websocket client: {}", debugId, e2.getMessage());
636 * Disconnect websocket session - initiated from this end.
638 * @param code error code for disconnecting the websocket
639 * @param reason reason for disconnecting the websocket
641 private void disconnect(LxErrorCode code, String reason) {
642 logger.debug("[{}] disconnect the websocket: {}, {}", debugId, code, reason);
643 socket.disconnect(code, reason);
645 logger.debug("[{}] client stop", debugId);
647 logger.debug("[{}] client stopped", debugId);
648 } catch (Exception e) {
649 logger.debug("[{}] Exception disconnecting the websocket: ", e.getMessage());
654 * Thread that maintains connection to the Miniserver.
655 * It will periodically attempt to connect and if failed, wait a configured amount of time.
656 * If connection succeeds, it will sleep until the session is terminated. Then it will wait and try to reconnect
659 * @author Pawel Pieczul - initial contribution
662 private class LxServerThread extends Thread {
663 private int debugId = 0;
664 private long elapsed = 0;
665 private Instant lastKeepAlive;
667 LxServerThread(int id) {
673 logger.debug("[{}] Thread starting", debugId);
675 while (!isInterrupted()) {
676 sessionActive.set(connectSession());
677 processStateUpdates();
679 } catch (InterruptedException e) {
680 logger.debug("[{}] Thread interrupted", debugId);
682 disconnect(LxErrorCode.OK, "Thing is going down.");
683 logger.debug("[{}] Thread ending", debugId);
686 private boolean connectSession() throws InterruptedException {
687 int delay = reconnectDelay.get();
689 logger.debug("[{}] Delaying connect request by {} seconds.", debugId, reconnectDelay);
690 TimeUnit.SECONDS.sleep(delay);
692 logger.debug("[{}] Server connecting to websocket", debugId);
694 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR,
695 "Failed to connect to Miniserver's WebSocket");
696 reconnectDelay.set(bindingConfig.connectErrDelay);
699 lastKeepAlive = Instant.now();
703 private void processStateUpdates() throws InterruptedException {
704 while (sessionActive.get()) {
705 logger.debug("[{}] Sleeping for {} seconds.", debugId, bindingConfig.keepAlivePeriod - elapsed);
706 LxStateUpdate update = stateUpdateQueue.poll(bindingConfig.keepAlivePeriod - elapsed, TimeUnit.SECONDS);
707 elapsed = Duration.between(lastKeepAlive, Instant.now()).getSeconds();
708 if (update == null || elapsed >= bindingConfig.keepAlivePeriod) {
712 if (update != null) {
713 updateStateValue(update);
718 private void sendKeepAlive() {
719 socket.sendKeepAlive();
720 lastKeepAlive = Instant.now();
726 * Updates the thing status to offline, if it is not already offline. This will preserve he first reason of going
727 * offline in case there were multiple reasons.
729 * @param code error code
730 * @param reason reason for going offline
732 private void updateStatusToOffline(ThingStatusDetail code, String reason) {
733 ThingStatus status = getThing().getStatus();
734 if (status == ThingStatus.OFFLINE) {
735 logger.debug("[{}] received offline request with code {}, but thing already offline.", debugId, code);
737 updateStatus(ThingStatus.OFFLINE, code, reason);
742 * Updates an actual state of a channel.
743 * Determines control for the channel and retrieves the state from the control.
745 * @param channelId channel ID to update its state
747 private void updateChannelState(ChannelUID channelId) {
748 LxControl control = channels.get(channelId);
749 if (control != null) {
750 State state = control.getChannelState(channelId);
752 updateState(channelId, state);
755 logger.error("[{}] Received state update request for unknown control (channelId={}).", debugId, channelId);
760 * Check and convert null string to empty string.
762 * @param name string to check
763 * @return string guaranteed to be not null
765 private String buildName(String name) {