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.HashMap;
23 import java.util.List;
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;
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;
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/apiKey";
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 = Set.of(LxBindingConstants.THING_TYPE_MINISERVER);
110 private QueuedThreadPool jettyThreadPool;
113 GsonBuilder builder = new GsonBuilder();
114 builder.registerTypeAdapter(LxUuid.class, LxUuid.DESERIALIZER);
115 builder.registerTypeAdapter(LxControl.class, LxControl.DESERIALIZER);
116 GSON = builder.create();
120 * Create {@link LxServerHandler} object
122 * @param thing Thing object that creates the handler
123 * @param provider state description provider service
125 public LxServerHandler(Thing thing, LxDynamicStateDescriptionProvider provider) {
127 logger.debug("[{}] Constructing thing object", debugId);
128 if (provider != null) {
129 dynamicStateDescriptionProvider = provider;
131 logger.warn("Dynamic state description provider is null");
136 * Methods from BaseThingHandler
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);
147 LxControl control = channels.get(channelUID);
148 if (control != null) {
149 logger.debug("[{}] Dispatching command to control UUID={}, name={}", debugId, control.getUuid(),
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(new SslContextFactory.Client(true));
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 s) ? s : 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));
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.
477 * @param update Miniserver's update event
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());
486 state.setStateValue(update.getValue());
488 if (perStateUuid.isEmpty()) {
489 logger.debug("[{}] State update UUID={} has empty controls table", debugId, update.getUuid());
492 logger.debug("[{}] State update UUID={} has no controls table", debugId, update.getUuid());
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.
504 * @param control a created control object to be added
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);
515 perUuid.put(uuid, state);
517 controls.put(control.getUuid(), control);
518 control.getChannels().forEach(channel -> channels.put(channel.getUID(), control));
519 control.getSubControls().values().forEach(subControl -> addControlStructures(subControl));
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.
527 * @param newChannels a list of channels to add to the thing
528 * @param purge if true, old channels will be removed, otherwise merged
530 private void addThingChannels(List<Channel> newChannels, boolean purge) {
531 List<Channel> channels = newChannels;
533 channels.addAll(getThing().getChannels());
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) {
542 } else if (label1 != null && label2 == null) {
548 ThingBuilder builder = editThing();
549 builder.withChannels(channels);
550 updateThing(builder.build());
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.
558 * @return true if connection request initiated correctly, false if not
560 private boolean connect() {
561 logger.debug("[{}] connect() websocket", debugId);
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.
566 boolean httpsCapable = false;
567 String message = socket.httpGet(CMD_CFG_API);
568 if (message != null) {
569 LxResponse resp = socket.getResponse(message);
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;
579 logger.debug("[{}] Http get failed for API config request.", debugId);
582 switch (bindingConfig.webSocketType) {
584 // keep automatically determined option
587 logger.debug("[{}] Forcing HTTPS websocket connection.", debugId);
591 logger.debug("[{}] Forcing HTTP websocket connection.", debugId);
592 httpsCapable = false;
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);
605 target = new URI("wss://" + host.getHostAddress() + ":" + bindingConfig.httpsPort + SOCKET_URL);
606 socket.setHttps(true);
608 target = new URI("ws://" + host.getHostAddress() + ":" + bindingConfig.port + SOCKET_URL);
609 socket.setHttps(false);
611 ClientUpgradeRequest request = new ClientUpgradeRequest();
612 request.setSubProtocols("remotecontrol");
614 socket.startResponseTimeout();
615 logger.debug("[{}] Connecting to server : {} ", debugId, target);
616 wsClient.connect(socket, target, request);
618 } catch (Exception e) {
619 logger.debug("[{}] Error starting websocket client: {}", debugId, e.getMessage());
622 } catch (Exception e2) {
623 logger.debug("[{}] Error stopping websocket client: {}", debugId, e2.getMessage());
634 * Disconnect websocket session - initiated from this end.
636 * @param code error code for disconnecting the websocket
637 * @param reason reason for disconnecting the websocket
639 private void disconnect(LxErrorCode code, String reason) {
640 logger.debug("[{}] disconnect the websocket: {}, {}", debugId, code, reason);
641 socket.disconnect(code, reason);
643 logger.debug("[{}] client stop", debugId);
645 logger.debug("[{}] client stopped", debugId);
646 } catch (Exception e) {
647 logger.debug("[{}] Exception disconnecting the websocket: ", e.getMessage());
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
657 * @author Pawel Pieczul - initial contribution
660 private class LxServerThread extends Thread {
661 private int debugId = 0;
662 private long elapsed = 0;
663 private Instant lastKeepAlive;
665 LxServerThread(int id) {
671 logger.debug("[{}] Thread starting", debugId);
673 while (!isInterrupted()) {
674 sessionActive.set(connectSession());
675 processStateUpdates();
677 } catch (InterruptedException e) {
678 logger.debug("[{}] Thread interrupted", debugId);
680 disconnect(LxErrorCode.OK, "Thing is going down.");
681 logger.debug("[{}] Thread ending", debugId);
684 private boolean connectSession() throws InterruptedException {
685 int delay = reconnectDelay.get();
687 logger.debug("[{}] Delaying connect request by {} seconds.", debugId, reconnectDelay);
688 TimeUnit.SECONDS.sleep(delay);
690 logger.debug("[{}] Server connecting to websocket", debugId);
692 updateStatusToOffline(ThingStatusDetail.COMMUNICATION_ERROR,
693 "Failed to connect to Miniserver's WebSocket");
694 reconnectDelay.set(bindingConfig.connectErrDelay);
697 lastKeepAlive = Instant.now();
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) {
710 if (update != null) {
711 updateStateValue(update);
716 private void sendKeepAlive() {
717 socket.sendKeepAlive();
718 lastKeepAlive = Instant.now();
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.
727 * @param code error code
728 * @param reason reason for going offline
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);
735 updateStatus(ThingStatus.OFFLINE, code, reason);
740 * Updates an actual state of a channel.
741 * Determines control for the channel and retrieves the state from the control.
743 * @param channelId channel ID to update its state
745 private void updateChannelState(ChannelUID channelId) {
746 LxControl control = channels.get(channelId);
747 if (control != null) {
748 State state = control.getChannelState(channelId);
750 updateState(channelId, state);
753 logger.error("[{}] Received state update request for unknown control (channelId={}).", debugId, channelId);
758 * Check and convert null string to empty string.
760 * @param name string to check
761 * @return string guaranteed to be not null
763 private String buildName(String name) {