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.adorne.internal.hub;
15 import static org.openhab.binding.adorne.internal.AdorneBindingConstants.*;
17 import java.io.IOException;
18 import java.util.ArrayList;
19 import java.util.HashMap;
20 import java.util.List;
22 import java.util.concurrent.CompletableFuture;
23 import java.util.concurrent.Future;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.atomic.AtomicInteger;
27 import java.util.function.IntUnaryOperator;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.adorne.internal.AdorneDeviceState;
32 import org.openhab.binding.adorne.internal.configuration.AdorneHubConfiguration;
33 import org.openhab.core.thing.ThingTypeUID;
34 import org.slf4j.Logger;
35 import org.slf4j.LoggerFactory;
37 import com.google.gson.JsonArray;
38 import com.google.gson.JsonObject;
39 import com.google.gson.JsonParseException;
40 import com.google.gson.JsonPrimitive;
43 * The {@link AdorneHubController} manages the interaction with the Adorne hub. The controller maintains a connection
44 * with the Adorne Hub and listens to device changes and issues device commands. Interaction with the hub is performed
45 * asynchronously through REST messages.
47 * @author Mark Theiding - Initial Contribution
50 public class AdorneHubController {
51 private final Logger logger = LoggerFactory.getLogger(AdorneHubController.class);
53 private static final int HUB_CONNECT_TIMEOUT = 10000;
54 private static final int HUB_RECONNECT_SLEEP_MINIMUM = 1;
55 private static final int HUB_RECONNECT_SLEEP_MAXIMUM = 15 * 60;
58 private static final String HUB_REST_SET_ONOFF = "{\"ID\":%d,\"Service\":\"SetZoneProperties\",\"ZID\":%d,\"PropertyList\":{\"Power\":%b}}\0";
59 private static final String HUB_REST_SET_BRIGHTNESS = "{\"ID\":%d,\"Service\":\"SetZoneProperties\",\"ZID\":%d,\"PropertyList\":{\"PowerLevel\":%d}}\0";
60 private static final String HUB_REST_REQUEST_STATE = "{\"ID\":%d,\"Service\":\"ReportZoneProperties\",\"ZID\":%d}\0";
61 private static final String HUB_REST_REQUEST_ZONES = "{\"ID\":%d,\"Service\":\"ListZones\"}\0";
62 private static final String HUB_REST_REQUEST_MACADDRESS = "{\"ID\":%d,\"Service\":\"SystemInfo\"}\0";
63 private static final String HUB_TOKEN_SERVICE = "Service";
64 private static final String HUB_TOKEN_ZID = "ZID";
65 private static final String HUB_TOKEN_PROPERTY_LIST = "PropertyList";
66 private static final String HUB_TOKEN_DEVICE_TYPE = "DeviceType";
67 private static final String HUB_TOKEN_SWITCH = "Switch";
68 private static final String HUB_TOKEN_DIMMER = "Dimmer";
69 private static final String HUB_TOKEN_NAME = "Name";
70 private static final String HUB_TOKEN_POWER = "Power";
71 private static final String HUB_TOKEN_POWER_LEVEL = "PowerLevel";
72 private static final String HUB_TOKEN_MAC_ADDRESS = "MACAddress";
73 private static final String HUB_TOKEN_ZONE_LIST = "ZoneList";
74 private static final String HUB_SERVICE_REPORT_ZONE_PROPERTIES = "ReportZoneProperties";
75 private static final String HUB_SERVICE_ZONE_PROPERTIES_CHANGED = "ZonePropertiesChanged";
76 private static final String HUB_SERVICE_LIST_ZONE = "ListZones";
77 private static final String HUB_SERVICE_SYSTEM_INFO = "SystemInfo";
79 private @Nullable Future<?> hubController;
80 private final String hubHost;
82 private @Nullable AdorneHubConnection hubConnection;
83 private final CompletableFuture<@Nullable Void> hubControllerConnected;
84 private int hubReconnectSleep; // Sleep time before we attempt re-connect
85 private final ScheduledExecutorService scheduler;
87 private volatile boolean stopWhenCommandsServed; // Stop the controller once all pending commands have been served
89 // When we submit commmands to the hub we don't correlate commands and responses. We simply use the first available
90 // response that answers our question. For that we store all pending commands.
91 // Note that for optimal resiliency we send a new request for each command even if a request is already pending
92 private final Map<Integer, CompletableFuture<AdorneDeviceState>> stateCommands;
93 private @Nullable CompletableFuture<List<Integer>> zoneCommand;
94 private @Nullable CompletableFuture<String> macAddressCommand;
95 private final AtomicInteger commandId; // We assign increasing command ids to all REST commands to the hub for
96 // easier troubleshooting
98 private final AdorneHubChangeNotify changeListener;
100 private final Object stopLock;
101 private final Object hubConnectionLock;
102 private final Object macAddressCommandLock;
103 private final Object zoneCommandLock;
105 public AdorneHubController(AdorneHubConfiguration config, ScheduledExecutorService scheduler,
106 AdorneHubChangeNotify changeListener) {
107 hubHost = config.host;
108 hubPort = config.port;
109 this.scheduler = scheduler;
110 this.changeListener = changeListener;
111 hubController = null;
112 hubConnection = null;
113 hubControllerConnected = new CompletableFuture<>();
114 hubReconnectSleep = HUB_RECONNECT_SLEEP_MINIMUM;
116 stopWhenCommandsServed = false;
118 stopLock = new Object();
119 hubConnectionLock = new Object();
120 macAddressCommandLock = new Object();
121 zoneCommandLock = new Object();
123 stateCommands = new HashMap<>();
125 macAddressCommand = null;
126 commandId = new AtomicInteger(0);
130 * Start the hub controller. Call only once.
132 * @return Future to inform the caller that the hub controller is ready for receiving commands
134 public CompletableFuture<@Nullable Void> start() {
135 logger.info("Starting hub controller");
136 hubController = scheduler.submit(this::msgLoop);
137 return hubControllerConnected;
141 * Stops the hub controller. Can't restart afterwards. If called before start nothing happens.
144 logger.info("Stopping hub controller");
145 synchronized (stopLock) {
146 // Canceling the controller tells the message loop to stop and also cancels recreation of the message loop
147 // if that is pending after a disconnect.
148 Future<?> hubController = this.hubController;
149 if (hubController != null) {
150 hubController.cancel(true);
154 // Stop the input stream in case controller is waiting on input
155 // Note this is best effort. If we are unlucky the hub can still enter waiting on input just after our stop
156 // here. Because waiting on input is long-running we can't just synchronize it with the stop check as case 2
157 // above. But that is ok as waiting on input has a timeout and will honor stop after that.
158 synchronized (hubConnectionLock) {
159 AdorneHubConnection hubConnection = this.hubConnection;
160 if (hubConnection != null) {
161 hubConnection.cancel();
169 * Stops the hub controller once all in-flight commands have been executed.
171 public void stopWhenCommandsServed() {
172 stopWhenCommandsServed = true;
176 * Turns device on or off.
178 * @param zoneId the device's zone ID
179 * @param on true to turn on the device
181 public void setOnOff(int zoneId, boolean on) {
182 sendRestCmd(String.format(HUB_REST_SET_ONOFF, getNextCommandId(), zoneId, on));
186 * Sets the brightness for a device. Applies only to dimmer devices.
188 * @param zoneId the device's zone ID
189 * @param level A value from 1-100. Note that in particular value 0 is not supported, which means this method can't
190 * be used to turn off a dimmer.
192 public void setBrightness(int zoneId, int level) {
193 if (level < 1 || level > 100) {
194 throw new IllegalArgumentException();
196 sendRestCmd(String.format(HUB_REST_SET_BRIGHTNESS, getNextCommandId(), zoneId, level));
200 * Gets asynchronously the state for a device.
202 * @param zoneId the device's zone ID
203 * @return a future for the {@link AdorneDeviceState}
205 public CompletableFuture<AdorneDeviceState> getState(int zoneId) {
206 // Note that we send the REST command for resiliency even if there is a pending command
207 sendRestCmd(String.format(HUB_REST_REQUEST_STATE, getNextCommandId(), zoneId));
209 CompletableFuture<AdorneDeviceState> stateCommand;
210 synchronized (stateCommands) {
211 stateCommand = stateCommands.get(zoneId);
212 if (stateCommand == null) {
213 stateCommand = new CompletableFuture<>();
214 stateCommands.put(zoneId, stateCommand);
221 * Gets asynchronously all zone IDs that are in use on the hub.
223 * @return a future for the list of zone IDs
225 public CompletableFuture<List<Integer>> getZones() {
226 // Note that we send the REST command for resiliency even if there is a pending command
227 sendRestCmd(String.format(HUB_REST_REQUEST_ZONES, getNextCommandId()));
229 CompletableFuture<List<Integer>> zoneCommand;
230 synchronized (zoneCommandLock) {
231 zoneCommand = this.zoneCommand;
232 if (zoneCommand == null) {
233 this.zoneCommand = zoneCommand = new CompletableFuture<>();
240 * Gets asynchronously the MAC address of the hub.
242 * @return a future for the MAC address
244 public CompletableFuture<String> getMACAddress() {
245 // Note that we send the REST command for resiliency even if there is a pending command
246 sendRestCmd(String.format(HUB_REST_REQUEST_MACADDRESS, getNextCommandId()));
248 CompletableFuture<String> macAddressCommand;
249 synchronized (macAddressCommandLock) {
250 macAddressCommand = this.macAddressCommand;
251 if (macAddressCommand == null) {
252 this.macAddressCommand = macAddressCommand = new CompletableFuture<>();
255 return macAddressCommand;
258 private void sendRestCmd(String cmd) {
259 logger.debug("Sending command {}", cmd);
260 synchronized (hubConnectionLock) {
261 AdorneHubConnection hubConnection = this.hubConnection;
262 if (hubConnection != null) {
263 hubConnection.putMsg(cmd);
265 throw new IllegalStateException("Can't send command. Adorne Hub connection is not available.");
271 * Runs the controller message loop that is interacting with the Adorne Hub by sending commands and listening for
274 private void msgLoop() {
277 JsonPrimitive jsonService;
280 // Main message loop listening for updates from the hub
281 logger.debug("Starting message loop");
282 while (!shouldStop()) {
284 int sleep = hubReconnectSleep;
285 logger.debug("Waiting {} seconds before re-attempting to connect.", sleep);
286 if (hubReconnectSleep < HUB_RECONNECT_SLEEP_MAXIMUM) {
287 hubReconnectSleep = hubReconnectSleep * 2; // Increase sleep time exponentially
289 restartMsgLoop(sleep);
292 hubReconnectSleep = HUB_RECONNECT_SLEEP_MINIMUM; // Reset
297 AdorneHubConnection hubConnection = this.hubConnection;
298 if (hubConnection != null) {
299 hubMsg = hubConnection.getMsg();
301 } catch (JsonParseException e) {
302 logger.debug("Failed to read valid message {}", e.getMessage());
303 disconnect(); // Disconnect so we can recover
305 if (hubMsg == null) {
309 // Process message based on service type
310 if ((jsonService = hubMsg.getAsJsonPrimitive(HUB_TOKEN_SERVICE)) != null) {
311 service = jsonService.getAsString();
313 continue; // Ignore messages that don't have a service specified
316 if (service.equals(HUB_SERVICE_REPORT_ZONE_PROPERTIES)) {
317 processMsgReportZoneProperties(hubMsg);
318 } else if (service.equals(HUB_SERVICE_ZONE_PROPERTIES_CHANGED)) {
319 processMsgZonePropertiesChanged(hubMsg);
320 } else if (service.equals(HUB_SERVICE_LIST_ZONE)) {
321 processMsgListZone(hubMsg);
322 } else if (service.equals(HUB_SERVICE_SYSTEM_INFO)) {
323 processMsgSystemInfo(hubMsg);
326 } catch (RuntimeException e) {
327 logger.warn("Hub controller failed", e);
334 hubControllerConnected.cancel(false);
335 logger.info("Exiting hub controller");
338 private boolean shouldStop() {
339 boolean stateCommandsIsEmpty;
340 synchronized (stateCommands) {
341 stateCommandsIsEmpty = stateCommands.isEmpty();
343 boolean commandsServed = stopWhenCommandsServed && stateCommandsIsEmpty && (zoneCommand == null)
344 && (macAddressCommand == null);
346 return isCancelled() || commandsServed;
349 private boolean isCancelled() {
350 Future<?> hubController = this.hubController;
351 return hubController == null || hubController.isCancelled();
354 private boolean connect() {
356 if (hubConnection == null) {
357 hubConnection = new AdorneHubConnection(hubHost, hubPort, HUB_CONNECT_TIMEOUT);
358 logger.debug("Hub connection established");
360 // Working around an Adorne Hub bug: the first command sent from a new connection intermittently
361 // gets lost in the hub. We are requesting the MAC address here simply to get this fragile first
362 // command out of the way. Requesting the MAC address and ignoring the result doesn't do any harm.
365 hubControllerConnected.complete(null);
367 changeListener.connectionChangeNotify(true);
370 } catch (IOException e) {
371 logger.debug("Couldn't establish hub connection ({}).", e.getMessage());
376 private void disconnect() {
377 hubReconnectSleep = HUB_RECONNECT_SLEEP_MINIMUM; // Reset our reconnect sleep time
378 synchronized (hubConnectionLock) {
379 AdorneHubConnection hubConnection = this.hubConnection;
380 if (hubConnection != null) {
381 hubConnection.close();
382 this.hubConnection = null;
386 changeListener.connectionChangeNotify(false);
389 private void cancelCommands() {
390 // If there are still pending commands we need to cancel them
391 synchronized (stateCommands) {
392 stateCommands.forEach((zoneId, stateCommand) -> stateCommand.cancel(false));
393 stateCommands.clear();
395 synchronized (zoneCommandLock) {
396 CompletableFuture<List<Integer>> zoneCommand = this.zoneCommand;
397 if (zoneCommand != null) {
398 zoneCommand.cancel(false);
399 this.zoneCommand = null;
402 synchronized (macAddressCommandLock) {
403 CompletableFuture<String> macAddressCommand = this.macAddressCommand;
404 if (macAddressCommand != null) {
405 macAddressCommand.cancel(false);
406 this.macAddressCommand = null;
409 logger.debug("Cancelled commands");
412 private void restartMsgLoop(int sleep) {
413 synchronized (stopLock) {
414 if (!isCancelled()) {
415 this.hubController = scheduler.schedule(this::msgLoop, sleep, TimeUnit.SECONDS);
421 * The hub sent zone properties in response to a command.
423 private void processMsgReportZoneProperties(JsonObject hubMsg) {
424 int zoneId = hubMsg.getAsJsonPrimitive(HUB_TOKEN_ZID).getAsInt();
425 logger.debug("Reporting zone properties for zone ID {} ", zoneId);
427 JsonObject jsonPropertyList = hubMsg.getAsJsonObject(HUB_TOKEN_PROPERTY_LIST);
428 String deviceTypeStr = jsonPropertyList.getAsJsonPrimitive(HUB_TOKEN_DEVICE_TYPE).getAsString();
429 ThingTypeUID deviceType;
430 if (deviceTypeStr.equals(HUB_TOKEN_SWITCH)) {
431 deviceType = THING_TYPE_SWITCH;
432 } else if (deviceTypeStr.equals(HUB_TOKEN_DIMMER)) {
433 deviceType = THING_TYPE_DIMMER;
435 logger.debug("Unsupported device type {}", deviceTypeStr);
438 AdorneDeviceState state = new AdorneDeviceState(zoneId,
439 jsonPropertyList.getAsJsonPrimitive(HUB_TOKEN_NAME).getAsString(), deviceType,
440 jsonPropertyList.getAsJsonPrimitive(HUB_TOKEN_POWER).getAsBoolean(),
441 jsonPropertyList.getAsJsonPrimitive(HUB_TOKEN_POWER_LEVEL).getAsInt());
443 synchronized (stateCommands) {
444 CompletableFuture<AdorneDeviceState> stateCommand = stateCommands.get(zoneId);
445 if (stateCommand != null) {
446 stateCommand.complete(state);
447 stateCommands.remove(zoneId);
453 * The hub informs us about a zone's change in properties.
455 private void processMsgZonePropertiesChanged(JsonObject hubMsg) {
456 int zoneId = hubMsg.getAsJsonPrimitive(HUB_TOKEN_ZID).getAsInt();
457 logger.debug("Zone properties changed for zone ID {} ", zoneId);
459 JsonObject jsonPropertyList = hubMsg.getAsJsonObject(HUB_TOKEN_PROPERTY_LIST);
460 boolean onOff = jsonPropertyList.getAsJsonPrimitive(HUB_TOKEN_POWER).getAsBoolean();
461 int brightness = jsonPropertyList.getAsJsonPrimitive(HUB_TOKEN_POWER_LEVEL).getAsInt();
462 changeListener.stateChangeNotify(zoneId, onOff, brightness);
466 * The hub sent a list of zones in response to a command.
468 private void processMsgListZone(JsonObject hubMsg) {
469 List<Integer> zones = new ArrayList<>();
470 JsonArray jsonZoneList;
472 jsonZoneList = hubMsg.getAsJsonArray(HUB_TOKEN_ZONE_LIST);
473 jsonZoneList.forEach(jsonZoneId -> {
474 JsonPrimitive jsonZoneIdValue = ((JsonObject) jsonZoneId).getAsJsonPrimitive(HUB_TOKEN_ZID);
475 zones.add(jsonZoneIdValue.getAsInt());
478 synchronized (zoneCommandLock) {
479 CompletableFuture<List<Integer>> zoneCommand = this.zoneCommand;
480 if (zoneCommand != null) {
481 zoneCommand.complete(zones);
482 this.zoneCommand = null;
488 * The hub sent system info in response to a command.
490 private void processMsgSystemInfo(JsonObject hubMsg) {
491 synchronized (macAddressCommandLock) {
492 CompletableFuture<String> macAddressCommand = this.macAddressCommand;
493 if (macAddressCommand != null) {
494 macAddressCommand.complete(hubMsg.getAsJsonPrimitive(HUB_TOKEN_MAC_ADDRESS).getAsString());
495 this.macAddressCommand = null;
500 private int getNextCommandId() {
501 IntUnaryOperator op = commandId -> {
502 int newCommandId = commandId;
503 if (commandId == Integer.MAX_VALUE) {
506 return ++newCommandId;
509 return commandId.updateAndGet(op);