]> git.basschouten.com Git - openhab-addons.git/blob
628cd45e709f991806bd3c8ddad59ae27ecbc9a3
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.adorne.internal.hub;
14
15 import static org.openhab.binding.adorne.internal.AdorneBindingConstants.*;
16
17 import java.io.IOException;
18 import java.util.ArrayList;
19 import java.util.HashMap;
20 import java.util.List;
21 import java.util.Map;
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;
28
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;
36
37 import com.google.gson.JsonArray;
38 import com.google.gson.JsonObject;
39 import com.google.gson.JsonParseException;
40 import com.google.gson.JsonPrimitive;
41
42 /**
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.
46  *
47  * @author Mark Theiding - Initial Contribution
48  */
49 @NonNullByDefault
50 public class AdorneHubController {
51     private final Logger logger = LoggerFactory.getLogger(AdorneHubController.class);
52
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;
56
57     // Hub rest commands
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";
78
79     private @Nullable Future<?> hubController;
80     private final String hubHost;
81     private int hubPort;
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;
86
87     private volatile boolean stopWhenCommandsServed; // Stop the controller once all pending commands have been served
88
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
97
98     private final AdorneHubChangeNotify changeListener;
99
100     private final Object stopLock;
101     private final Object hubConnectionLock;
102     private final Object macAddressCommandLock;
103     private final Object zoneCommandLock;
104
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;
115
116         stopWhenCommandsServed = false;
117
118         stopLock = new Object();
119         hubConnectionLock = new Object();
120         macAddressCommandLock = new Object();
121         zoneCommandLock = new Object();
122
123         stateCommands = new HashMap<>();
124         zoneCommand = null;
125         macAddressCommand = null;
126         commandId = new AtomicInteger(0);
127     }
128
129     /**
130      * Start the hub controller. Call only once.
131      *
132      * @return Future to inform the caller that the hub controller is ready for receiving commands
133      */
134     public CompletableFuture<@Nullable Void> start() {
135         logger.info("Starting hub controller");
136         hubController = scheduler.submit(this::msgLoop);
137         return hubControllerConnected;
138     }
139
140     /**
141      * Stops the hub controller. Can't restart afterwards. If called before start nothing happens.
142      */
143     public void stop() {
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);
151             }
152         }
153
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();
162             }
163         }
164
165         cancelCommands();
166     }
167
168     /**
169      * Stops the hub controller once all in-flight commands have been executed.
170      */
171     public void stopWhenCommandsServed() {
172         stopWhenCommandsServed = true;
173     }
174
175     /**
176      * Turns device on or off.
177      *
178      * @param zoneId the device's zone ID
179      * @param on true to turn on the device
180      */
181     public void setOnOff(int zoneId, boolean on) {
182         sendRestCmd(String.format(HUB_REST_SET_ONOFF, getNextCommandId(), zoneId, on));
183     }
184
185     /**
186      * Sets the brightness for a device. Applies only to dimmer devices.
187      *
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.
191      */
192     public void setBrightness(int zoneId, int level) {
193         if (level < 1 || level > 100) {
194             throw new IllegalArgumentException();
195         }
196         sendRestCmd(String.format(HUB_REST_SET_BRIGHTNESS, getNextCommandId(), zoneId, level));
197     }
198
199     /**
200      * Gets asynchronously the state for a device.
201      *
202      * @param zoneId the device's zone ID
203      * @return a future for the {@link AdorneDeviceState}
204      */
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));
208
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);
215             }
216         }
217         return stateCommand;
218     }
219
220     /**
221      * Gets asynchronously all zone IDs that are in use on the hub.
222      *
223      * @return a future for the list of zone IDs
224      */
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()));
228
229         CompletableFuture<List<Integer>> zoneCommand;
230         synchronized (zoneCommandLock) {
231             zoneCommand = this.zoneCommand;
232             if (zoneCommand == null) {
233                 this.zoneCommand = zoneCommand = new CompletableFuture<>();
234             }
235         }
236         return zoneCommand;
237     }
238
239     /**
240      * Gets asynchronously the MAC address of the hub.
241      *
242      * @return a future for the MAC address
243      */
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()));
247
248         CompletableFuture<String> macAddressCommand;
249         synchronized (macAddressCommandLock) {
250             macAddressCommand = this.macAddressCommand;
251             if (macAddressCommand == null) {
252                 this.macAddressCommand = macAddressCommand = new CompletableFuture<>();
253             }
254         }
255         return macAddressCommand;
256     }
257
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);
264             } else {
265                 throw new IllegalStateException("Can't send command. Adorne Hub connection is not available.");
266             }
267         }
268     }
269
270     /**
271      * Runs the controller message loop that is interacting with the Adorne Hub by sending commands and listening for
272      * updates
273      */
274     private void msgLoop() {
275         try {
276             JsonObject hubMsg;
277             JsonPrimitive jsonService;
278             String service;
279
280             // Main message loop listening for updates from the hub
281             logger.debug("Starting message loop");
282             while (!shouldStop()) {
283                 if (!connect()) {
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
288                     }
289                     restartMsgLoop(sleep);
290                     return;
291                 } else {
292                     hubReconnectSleep = HUB_RECONNECT_SLEEP_MINIMUM; // Reset
293                 }
294
295                 hubMsg = null;
296                 try {
297                     AdorneHubConnection hubConnection = this.hubConnection;
298                     if (hubConnection != null) {
299                         hubMsg = hubConnection.getMsg();
300                     }
301                 } catch (JsonParseException e) {
302                     logger.debug("Failed to read valid message {}", e.getMessage());
303                     disconnect(); // Disconnect so we can recover
304                 }
305                 if (hubMsg == null) {
306                     continue;
307                 }
308
309                 // Process message based on service type
310                 if ((jsonService = hubMsg.getAsJsonPrimitive(HUB_TOKEN_SERVICE)) != null) {
311                     service = jsonService.getAsString();
312                 } else {
313                     continue; // Ignore messages that don't have a service specified
314                 }
315
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);
324                 }
325             }
326         } catch (RuntimeException e) {
327             logger.warn("Hub controller failed", e);
328         }
329
330         // Shut down
331         disconnect();
332
333         cancelCommands();
334         hubControllerConnected.cancel(false);
335         logger.info("Exiting hub controller");
336     }
337
338     private boolean shouldStop() {
339         boolean stateCommandsIsEmpty;
340         synchronized (stateCommands) {
341             stateCommandsIsEmpty = stateCommands.isEmpty();
342         }
343         boolean commandsServed = stopWhenCommandsServed && stateCommandsIsEmpty && (zoneCommand == null)
344                 && (macAddressCommand == null);
345
346         return isCancelled() || commandsServed;
347     }
348
349     private boolean isCancelled() {
350         Future<?> hubController = this.hubController;
351         return hubController == null || hubController.isCancelled();
352     }
353
354     private boolean connect() {
355         try {
356             if (hubConnection == null) {
357                 hubConnection = new AdorneHubConnection(hubHost, hubPort, HUB_CONNECT_TIMEOUT);
358                 logger.debug("Hub connection established");
359
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.
363                 getMACAddress();
364
365                 hubControllerConnected.complete(null);
366
367                 changeListener.connectionChangeNotify(true);
368             }
369             return true;
370         } catch (IOException e) {
371             logger.debug("Couldn't establish hub connection ({}).", e.getMessage());
372             return false;
373         }
374     }
375
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;
383             }
384         }
385
386         changeListener.connectionChangeNotify(false);
387     }
388
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();
394         }
395         synchronized (zoneCommandLock) {
396             CompletableFuture<List<Integer>> zoneCommand = this.zoneCommand;
397             if (zoneCommand != null) {
398                 zoneCommand.cancel(false);
399                 this.zoneCommand = null;
400             }
401         }
402         synchronized (macAddressCommandLock) {
403             CompletableFuture<String> macAddressCommand = this.macAddressCommand;
404             if (macAddressCommand != null) {
405                 macAddressCommand.cancel(false);
406                 this.macAddressCommand = null;
407             }
408         }
409         logger.debug("Cancelled commands");
410     }
411
412     private void restartMsgLoop(int sleep) {
413         synchronized (stopLock) {
414             if (!isCancelled()) {
415                 this.hubController = scheduler.schedule(this::msgLoop, sleep, TimeUnit.SECONDS);
416             }
417         }
418     }
419
420     /**
421      * The hub sent zone properties in response to a command.
422      */
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);
426
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;
434         } else {
435             logger.debug("Unsupported device type {}", deviceTypeStr);
436             return;
437         }
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());
442
443         synchronized (stateCommands) {
444             CompletableFuture<AdorneDeviceState> stateCommand = stateCommands.get(zoneId);
445             if (stateCommand != null) {
446                 stateCommand.complete(state);
447                 stateCommands.remove(zoneId);
448             }
449         }
450     }
451
452     /**
453      * The hub informs us about a zone's change in properties.
454      */
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);
458
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);
463     }
464
465     /**
466      * The hub sent a list of zones in response to a command.
467      */
468     private void processMsgListZone(JsonObject hubMsg) {
469         List<Integer> zones = new ArrayList<>();
470         JsonArray jsonZoneList;
471
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());
476         });
477
478         synchronized (zoneCommandLock) {
479             CompletableFuture<List<Integer>> zoneCommand = this.zoneCommand;
480             if (zoneCommand != null) {
481                 zoneCommand.complete(zones);
482                 this.zoneCommand = null;
483             }
484         }
485     }
486
487     /**
488      * The hub sent system info in response to a command.
489      */
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;
496             }
497         }
498     }
499
500     private int getNextCommandId() {
501         IntUnaryOperator op = commandId -> {
502             int newCommandId = commandId;
503             if (commandId == Integer.MAX_VALUE) {
504                 newCommandId = 0;
505             }
506             return ++newCommandId;
507         };
508
509         return commandId.updateAndGet(op);
510     }
511 }