]> git.basschouten.com Git - openhab-addons.git/blob
8347fb4cdc366145190b13ecb9a9f2e74dbd7934
[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.lutron.internal.handler;
14
15 import static org.openhab.binding.lutron.internal.LutronBindingConstants.*;
16
17 import java.io.BufferedReader;
18 import java.io.BufferedWriter;
19 import java.io.FileInputStream;
20 import java.io.FileNotFoundException;
21 import java.io.IOException;
22 import java.io.InputStreamReader;
23 import java.io.InterruptedIOException;
24 import java.io.OutputStreamWriter;
25 import java.net.UnknownHostException;
26 import java.security.KeyManagementException;
27 import java.security.KeyStore;
28 import java.security.KeyStoreException;
29 import java.security.NoSuchAlgorithmException;
30 import java.security.UnrecoverableKeyException;
31 import java.security.cert.CertificateException;
32 import java.security.cert.X509Certificate;
33 import java.util.Arrays;
34 import java.util.Collection;
35 import java.util.Collections;
36 import java.util.HashMap;
37 import java.util.List;
38 import java.util.Map;
39 import java.util.concurrent.BlockingQueue;
40 import java.util.concurrent.ConcurrentHashMap;
41 import java.util.concurrent.Future;
42 import java.util.concurrent.LinkedBlockingQueue;
43 import java.util.concurrent.ScheduledFuture;
44 import java.util.concurrent.TimeUnit;
45
46 import javax.net.ssl.KeyManagerFactory;
47 import javax.net.ssl.SSLContext;
48 import javax.net.ssl.SSLSocket;
49 import javax.net.ssl.SSLSocketFactory;
50 import javax.net.ssl.TrustManager;
51 import javax.net.ssl.TrustManagerFactory;
52 import javax.net.ssl.X509TrustManager;
53
54 import org.eclipse.jdt.annotation.NonNullByDefault;
55 import org.eclipse.jdt.annotation.Nullable;
56 import org.openhab.binding.lutron.internal.config.LeapBridgeConfig;
57 import org.openhab.binding.lutron.internal.discovery.LeapDeviceDiscoveryService;
58 import org.openhab.binding.lutron.internal.protocol.FanSpeedType;
59 import org.openhab.binding.lutron.internal.protocol.GroupCommand;
60 import org.openhab.binding.lutron.internal.protocol.LutronCommandNew;
61 import org.openhab.binding.lutron.internal.protocol.OutputCommand;
62 import org.openhab.binding.lutron.internal.protocol.leap.LeapCommand;
63 import org.openhab.binding.lutron.internal.protocol.leap.LeapMessageParser;
64 import org.openhab.binding.lutron.internal.protocol.leap.LeapMessageParserCallbacks;
65 import org.openhab.binding.lutron.internal.protocol.leap.Request;
66 import org.openhab.binding.lutron.internal.protocol.leap.dto.Area;
67 import org.openhab.binding.lutron.internal.protocol.leap.dto.ButtonGroup;
68 import org.openhab.binding.lutron.internal.protocol.leap.dto.Device;
69 import org.openhab.binding.lutron.internal.protocol.leap.dto.OccupancyGroup;
70 import org.openhab.binding.lutron.internal.protocol.leap.dto.Project;
71 import org.openhab.binding.lutron.internal.protocol.leap.dto.ZoneStatus;
72 import org.openhab.binding.lutron.internal.protocol.lip.LutronCommandType;
73 import org.openhab.core.library.types.StringType;
74 import org.openhab.core.thing.Bridge;
75 import org.openhab.core.thing.ChannelUID;
76 import org.openhab.core.thing.Thing;
77 import org.openhab.core.thing.ThingStatus;
78 import org.openhab.core.thing.ThingStatusDetail;
79 import org.openhab.core.thing.ThingStatusInfo;
80 import org.openhab.core.thing.binding.ThingHandler;
81 import org.openhab.core.thing.binding.ThingHandlerService;
82 import org.openhab.core.types.Command;
83 import org.slf4j.Logger;
84 import org.slf4j.LoggerFactory;
85
86 /**
87  * Bridge handler responsible for communicating with Lutron hubs that support the LEAP protocol, such as Caseta and
88  * RA2 Select.
89  *
90  * @author Bob Adair - Initial contribution
91  */
92 @NonNullByDefault
93 public class LeapBridgeHandler extends LutronBridgeHandler implements LeapMessageParserCallbacks {
94     private static final int DEFAULT_RECONNECT_MINUTES = 5;
95     private static final int DEFAULT_HEARTBEAT_MINUTES = 5;
96     private static final long KEEPALIVE_TIMEOUT_SECONDS = 30;
97
98     private static final String STATUS_INITIALIZING = "Initializing";
99     private static final String LUTRON_RADIORA_3_PROJECT = "Lutron RadioRA 3 Project";
100
101     private final Logger logger = LoggerFactory.getLogger(LeapBridgeHandler.class);
102
103     private @NonNullByDefault({}) LeapBridgeConfig config;
104     private int reconnectInterval;
105     private int heartbeatInterval;
106     private int sendDelay;
107     private boolean isRadioRA3 = false;
108
109     private @NonNullByDefault({}) SSLSocketFactory sslsocketfactory;
110     private @Nullable SSLSocket sslsocket;
111     private @Nullable BufferedWriter writer;
112     private @Nullable BufferedReader reader;
113
114     private @NonNullByDefault({}) LeapMessageParser leapMessageParser;
115
116     private final BlockingQueue<LeapCommand> sendQueue = new LinkedBlockingQueue<>();
117
118     private @Nullable Future<?> asyncInitializeTask;
119
120     private @Nullable Thread senderThread;
121     private @Nullable Thread readerThread;
122
123     private @Nullable ScheduledFuture<?> keepAliveJob;
124     private @Nullable ScheduledFuture<?> keepAliveReconnectJob;
125     private @Nullable ScheduledFuture<?> connectRetryJob;
126     private final Object keepAliveReconnectLock = new Object();
127
128     private final Map<Integer, Integer> zoneToDevice = new HashMap<>();
129     private final Map<Integer, Integer> deviceToZone = new HashMap<>();
130     private final Object zoneMapsLock = new Object();
131
132     private @Nullable Map<Integer, List<Integer>> deviceButtonMap;
133     private final Object deviceButtonMapLock = new Object();
134
135     private volatile boolean deviceDataLoaded = false;
136     private volatile boolean buttonDataLoaded = false;
137
138     private final Map<Integer, LutronHandler> childHandlerMap = new ConcurrentHashMap<>();
139     private final Map<Integer, OGroupHandler> groupHandlerMap = new ConcurrentHashMap<>();
140
141     private @Nullable LeapDeviceDiscoveryService discoveryService;
142
143     public void setDiscoveryService(LeapDeviceDiscoveryService discoveryService) {
144         this.discoveryService = discoveryService;
145     }
146
147     public LeapBridgeHandler(Bridge bridge) {
148         super(bridge);
149         leapMessageParser = new LeapMessageParser(this);
150     }
151
152     @Override
153     public Collection<Class<? extends ThingHandlerService>> getServices() {
154         return Collections.singleton(LeapDeviceDiscoveryService.class);
155     }
156
157     @Override
158     public void initialize() {
159         SSLContext sslContext;
160
161         childHandlerMap.clear();
162         groupHandlerMap.clear();
163
164         config = getConfigAs(LeapBridgeConfig.class);
165         String keystorePassword = (config.keystorePassword == null) ? "" : config.keystorePassword;
166
167         String ipAddress = config.ipAddress;
168         if (ipAddress == null || ipAddress.isEmpty()) {
169             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridge address not specified");
170             return;
171         }
172
173         reconnectInterval = (config.reconnect > 0) ? config.reconnect : DEFAULT_RECONNECT_MINUTES;
174         heartbeatInterval = (config.heartbeat > 0) ? config.heartbeat : DEFAULT_HEARTBEAT_MINUTES;
175         sendDelay = (config.delay < 0) ? 0 : config.delay;
176
177         if (config.keystore == null || keystorePassword == null) {
178             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
179                     "Keystore/keystore password not configured");
180             return;
181         } else {
182             try (FileInputStream keystoreInputStream = new FileInputStream(config.keystore)) {
183                 logger.trace("Initializing keystore");
184                 KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
185
186                 keystore.load(keystoreInputStream, keystorePassword.toCharArray());
187
188                 logger.trace("Initializing SSL Context");
189                 KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
190                 kmf.init(keystore, keystorePassword.toCharArray());
191
192                 TrustManager[] trustManagers;
193                 if (config.certValidate) {
194                     // Use default trust manager which will attempt to validate server certificate from hub
195                     TrustManagerFactory tmf = TrustManagerFactory
196                             .getInstance(TrustManagerFactory.getDefaultAlgorithm());
197                     tmf.init(keystore);
198                     trustManagers = tmf.getTrustManagers();
199                 } else {
200                     // Use no-op trust manager which will not verify certificates
201                     trustManagers = defineNoOpTrustManager();
202                 }
203
204                 sslContext = SSLContext.getInstance("TLS");
205                 sslContext.init(kmf.getKeyManagers(), trustManagers, null);
206
207                 sslsocketfactory = sslContext.getSocketFactory();
208             } catch (FileNotFoundException e) {
209                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Keystore file not found");
210                 return;
211             } catch (CertificateException e) {
212                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Certificate exception");
213                 return;
214             } catch (UnrecoverableKeyException e) {
215                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
216                         "Key unrecoverable with supplied password");
217                 return;
218             } catch (KeyManagementException e) {
219                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Key management exception");
220                 logger.debug("Key management exception", e);
221                 return;
222             } catch (KeyStoreException | NoSuchAlgorithmException | IOException e) {
223                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Error initializing keystore");
224                 logger.debug("Error initializing keystore", e);
225                 return;
226             }
227         }
228
229         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Connecting");
230         asyncInitializeTask = scheduler.submit(this::connect); // start the async connect task
231     }
232
233     /**
234      * Return a no-op SSL trust manager which will not verify server or client certificates.
235      */
236     private TrustManager[] defineNoOpTrustManager() {
237         return new TrustManager[] { new X509TrustManager() {
238             @Override
239             public void checkClientTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) {
240                 logger.debug("Assuming client certificate is valid");
241                 if (chain != null && logger.isTraceEnabled()) {
242                     for (int cert = 0; cert < chain.length; cert++) {
243                         logger.trace("Subject DN: {}", chain[cert].getSubjectDN());
244                         logger.trace("Issuer DN: {}", chain[cert].getIssuerDN());
245                         logger.trace("Serial number {}:", chain[cert].getSerialNumber());
246                     }
247                 }
248             }
249
250             @Override
251             public void checkServerTrusted(final X509Certificate @Nullable [] chain, final @Nullable String authType) {
252                 logger.debug("Assuming server certificate is valid");
253                 if (chain != null && logger.isTraceEnabled()) {
254                     for (int cert = 0; cert < chain.length; cert++) {
255                         logger.trace("Subject DN: {}", chain[cert].getSubjectDN());
256                         logger.trace("Issuer DN: {}", chain[cert].getIssuerDN());
257                         logger.trace("Serial number: {}", chain[cert].getSerialNumber());
258                     }
259                 }
260             }
261
262             @Override
263             public X509Certificate @Nullable [] getAcceptedIssuers() {
264                 return null;
265             }
266         } };
267     }
268
269     private synchronized void connect() {
270         deviceDataLoaded = false;
271         buttonDataLoaded = false;
272
273         try {
274             logger.debug("Opening SSL connection to {}:{}", config.ipAddress, config.port);
275             SSLSocket sslsocket = (SSLSocket) sslsocketfactory.createSocket(config.ipAddress, config.port);
276             sslsocket.startHandshake();
277             writer = new BufferedWriter(new OutputStreamWriter(sslsocket.getOutputStream()));
278             reader = new BufferedReader(new InputStreamReader(sslsocket.getInputStream()));
279             this.sslsocket = sslsocket;
280         } catch (UnknownHostException e) {
281             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Unknown host");
282             return;
283         } catch (IllegalArgumentException e) {
284             // port out of valid range
285             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid port number");
286             return;
287         } catch (InterruptedIOException e) {
288             logger.debug("Interrupted while establishing connection");
289             Thread.currentThread().interrupt();
290             return;
291         } catch (IOException e) {
292             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
293                     "Error opening SSL connection. Check log.");
294             logger.info("Error opening SSL connection: {}", e.getMessage());
295             disconnect(false);
296             scheduleConnectRetry(reconnectInterval); // Possibly a temporary problem. Try again later.
297             return;
298         }
299
300         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, STATUS_INITIALIZING);
301
302         Thread readerThread = new Thread(this::readerThreadJob, "Lutron reader");
303         readerThread.setDaemon(true);
304         readerThread.start();
305         this.readerThread = readerThread;
306
307         Thread senderThread = new Thread(this::senderThreadJob, "Lutron sender");
308         senderThread.setDaemon(true);
309         senderThread.start();
310         this.senderThread = senderThread;
311
312         sendCommand(new LeapCommand(Request.getProject()));
313
314         logger.debug("Starting keepalive job with interval {}", heartbeatInterval);
315         keepAliveJob = scheduler.scheduleWithFixedDelay(this::sendKeepAlive, heartbeatInterval, heartbeatInterval,
316                 TimeUnit.MINUTES);
317     }
318
319     /**
320      * Called by connect() and discovery service to request fresh discovery data
321      */
322     public void queryDiscoveryData() {
323         if (!isRadioRA3) {
324             sendCommand(new LeapCommand(Request.getDevices()));
325         } else {
326             sendCommand(new LeapCommand(Request.getDevices(false)));
327         }
328         sendCommand(new LeapCommand(Request.getAreas()));
329         sendCommand(new LeapCommand(Request.getOccupancyGroups()));
330     }
331
332     private void scheduleConnectRetry(long waitMinutes) {
333         logger.debug("Scheduling connection retry in {} minutes", waitMinutes);
334         connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
335     }
336
337     /**
338      * Disconnect from bridge, cancel retry and keepalive jobs, stop reader and writer threads, and clean up.
339      *
340      * @param interruptAll Set if reconnect task should be interrupted if running. Should be false when calling from
341      *            connect or reconnect, and true when calling from dispose.
342      */
343     private synchronized void disconnect(boolean interruptAll) {
344         logger.debug("Disconnecting");
345
346         ScheduledFuture<?> connectRetryJob = this.connectRetryJob;
347         if (connectRetryJob != null) {
348             connectRetryJob.cancel(true);
349         }
350         ScheduledFuture<?> keepAliveJob = this.keepAliveJob;
351         if (keepAliveJob != null) {
352             keepAliveJob.cancel(true);
353         }
354
355         reconnectTaskCancel(interruptAll); // May be called from keepAliveReconnectJob thread
356
357         Thread senderThread = this.senderThread;
358         if (senderThread != null && senderThread.isAlive()) {
359             senderThread.interrupt();
360         }
361
362         Thread readerThread = this.readerThread;
363         if (readerThread != null && readerThread.isAlive()) {
364             readerThread.interrupt();
365         }
366         SSLSocket sslsocket = this.sslsocket;
367         if (sslsocket != null) {
368             try {
369                 sslsocket.close();
370             } catch (IOException e) {
371                 logger.debug("Error closing SSL socket: {}", e.getMessage());
372             }
373             this.sslsocket = null;
374         }
375         BufferedReader reader = this.reader;
376         if (reader != null) {
377             try {
378                 reader.close();
379             } catch (IOException e) {
380                 logger.debug("Error closing reader: {}", e.getMessage());
381             }
382         }
383         BufferedWriter writer = this.writer;
384         if (writer != null) {
385             try {
386                 writer.close();
387             } catch (IOException e) {
388                 logger.debug("Error closing writer: {}", e.getMessage());
389             }
390         }
391
392         deviceDataLoaded = false;
393         buttonDataLoaded = false;
394     }
395
396     private synchronized void reconnect() {
397         logger.debug("Attempting to reconnect to the bridge");
398         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "reconnecting");
399         disconnect(false);
400         connect();
401     }
402
403     /**
404      * Method executed by the message sender thread (senderThread)
405      */
406     private void senderThreadJob() {
407         logger.debug("Command sender thread started");
408         try {
409             while (!Thread.currentThread().isInterrupted() && writer != null) {
410                 LeapCommand command = sendQueue.take();
411                 logger.trace("Sending command {}", command);
412
413                 try {
414                     BufferedWriter writer = this.writer;
415                     if (writer != null) {
416                         writer.write(command.toString() + "\n");
417                         writer.flush();
418                     }
419                 } catch (InterruptedIOException e) {
420                     logger.debug("Interrupted while sending");
421                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Interrupted");
422                     break; // exit loop and terminate thread
423                 } catch (IOException e) {
424                     logger.warn("Communication error, will try to reconnect. Error: {}", e.getMessage());
425                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
426                     sendQueue.add(command); // Requeue command
427                     reconnect();
428                     break; // reconnect() will start a new thread; terminate this one
429                 }
430                 if (sendDelay > 0) {
431                     Thread.sleep(sendDelay); // introduce delay to throttle send rate
432                 }
433             }
434         } catch (InterruptedException e) {
435             Thread.currentThread().interrupt();
436         } finally {
437             logger.debug("Command sender thread exiting");
438         }
439     }
440
441     /**
442      * Method executed by the message reader thread (readerThread)
443      */
444     private void readerThreadJob() {
445         logger.debug("Message reader thread started");
446         String msg = null;
447         try {
448             BufferedReader reader = this.reader;
449             while (!Thread.interrupted() && reader != null && (msg = reader.readLine()) != null) {
450                 leapMessageParser.handleMessage(msg);
451             }
452             if (msg == null) {
453                 logger.debug("End of input stream detected");
454                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection lost");
455             }
456         } catch (InterruptedIOException e) {
457             logger.debug("Interrupted while reading");
458             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Interrupted");
459         } catch (IOException e) {
460             logger.debug("I/O error while reading from stream: {}", e.getMessage());
461             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
462         } catch (RuntimeException e) {
463             logger.warn("Runtime exception in reader thread", e);
464             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
465         } finally {
466             logger.debug("Message reader thread exiting");
467         }
468     }
469
470     /**
471      * Called if NoContent response received for a buttongroup read request. Creates empty deviceButtonMap.
472      */
473     @Override
474     public void handleEmptyButtonGroupDefinition() {
475         logger.debug("No content in button group definition. Creating empty deviceButtonMap.");
476         Map<Integer, List<Integer>> deviceButtonMap = new HashMap<>();
477         synchronized (deviceButtonMapLock) {
478             this.deviceButtonMap = deviceButtonMap;
479             buttonDataLoaded = true;
480         }
481         checkInitialized();
482     }
483
484     /**
485      * Set state to online if offline/initializing and all required initialization info is loaded.
486      * Currently this means device (zone) and button group data.
487      */
488     private void checkInitialized() {
489         ThingStatusInfo statusInfo = getThing().getStatusInfo();
490         if (statusInfo.getStatus() == ThingStatus.OFFLINE && STATUS_INITIALIZING.equals(statusInfo.getDescription())) {
491             if (deviceDataLoaded && buttonDataLoaded) {
492                 updateStatus(ThingStatus.ONLINE);
493             }
494         }
495     }
496
497     /**
498      * Notify child thing handler of a zonelevel update from a received zone status message.
499      */
500     @Override
501     public void handleZoneUpdate(ZoneStatus zoneStatus) {
502         logger.trace("Zone: {} level: {}", zoneStatus.getZone(), zoneStatus.level);
503         Integer integrationId = zoneToDevice(zoneStatus.getZone());
504
505         if (integrationId == null) {
506             logger.debug("Unable to map zone {} to device", zoneStatus.getZone());
507             return;
508         }
509         logger.trace("Zone {} mapped to device id {}", zoneStatus.getZone(), integrationId);
510
511         // dispatch update to proper thing handler
512         LutronHandler handler = findThingHandler(integrationId);
513         if (handler != null) {
514             if (zoneStatus.fanSpeed != null) {
515                 // handle fan controller
516                 FanSpeedType fanSpeed = zoneStatus.fanSpeed;
517                 try {
518                     handler.handleUpdate(LutronCommandType.OUTPUT, OutputCommand.ACTION_ZONELEVEL.toString(),
519                             Integer.valueOf(fanSpeed.speed()).toString());
520                 } catch (NumberFormatException e) {
521                     logger.warn("Number format exception parsing update");
522                 } catch (RuntimeException e) {
523                     logger.warn("Runtime exception while processing update");
524                 }
525             } else {
526                 // handle switch/dimmer/shade
527                 try {
528                     handler.handleUpdate(LutronCommandType.OUTPUT, OutputCommand.ACTION_ZONELEVEL.toString(),
529                             Integer.valueOf(zoneStatus.level).toString());
530                 } catch (NumberFormatException e) {
531                     logger.warn("Number format exception parsing update");
532                 } catch (RuntimeException e) {
533                     logger.warn("Runtime exception while processing update");
534                 }
535             }
536         } else {
537             logger.debug("No thing configured for integration ID {}", integrationId);
538         }
539     }
540
541     /**
542      * Notify child group handler of a received occupancy group update.
543      *
544      * @param occupancyStatus
545      * @param groupNumber
546      */
547     @Override
548     public void handleGroupUpdate(int groupNumber, String occupancyStatus) {
549         logger.trace("Group {} state update: {}", groupNumber, occupancyStatus);
550
551         // dispatch update to proper handler
552         OGroupHandler handler = findGroupHandler(groupNumber);
553         if (handler != null) {
554             try {
555                 switch (occupancyStatus) {
556                     case "Occupied":
557                         handler.handleUpdate(LutronCommandType.GROUP, GroupCommand.ACTION_GROUPSTATE.toString(),
558                                 GroupCommand.STATE_GRP_OCCUPIED.toString());
559                         break;
560                     case "Unoccupied":
561                         handler.handleUpdate(LutronCommandType.GROUP, GroupCommand.ACTION_GROUPSTATE.toString(),
562                                 GroupCommand.STATE_GRP_UNOCCUPIED.toString());
563                         break;
564                     case "Unknown":
565                         handler.handleUpdate(LutronCommandType.GROUP, GroupCommand.ACTION_GROUPSTATE.toString(),
566                                 GroupCommand.STATE_GRP_UNKNOWN.toString());
567                         break;
568                     default:
569                         logger.debug("Unexpected occupancy status: {}", occupancyStatus);
570                         return;
571                 }
572             } catch (NumberFormatException e) {
573                 logger.warn("Number format exception parsing update");
574             } catch (RuntimeException e) {
575                 logger.warn("Runtime exception while processing update");
576             }
577         } else {
578             logger.debug("No group thing configured for group ID {}", groupNumber);
579         }
580     }
581
582     @Override
583     public void handleMultipleButtonGroupDefinition(List<ButtonGroup> buttonGroupList) {
584         Map<Integer, List<Integer>> deviceButtonMap = new HashMap<>();
585
586         for (ButtonGroup buttonGroup : buttonGroupList) {
587             int parentDevice = buttonGroup.getParentDevice();
588             logger.trace("Found ButtonGroup: {} parent device: {}", buttonGroup.getButtonGroup(), parentDevice);
589             List<Integer> buttonList = buttonGroup.getButtonList();
590             deviceButtonMap.put(parentDevice, buttonList);
591         }
592         synchronized (deviceButtonMapLock) {
593             this.deviceButtonMap = deviceButtonMap;
594             buttonDataLoaded = true;
595         }
596         checkInitialized();
597     }
598
599     @Override
600     public void handleDeviceDefinition(Device device) {
601         synchronized (zoneMapsLock) {
602             int deviceId = device.getDevice();
603             int zoneId = device.getZone();
604
605             if (zoneId > 0 && deviceId > 0) {
606                 zoneToDevice.put(zoneId, deviceId);
607                 deviceToZone.put(deviceId, zoneId);
608             }
609
610             if (deviceId == 1 || device.isThisDevice) {
611                 setBridgeProperties(device);
612             }
613         }
614
615         checkInitialized();
616
617         LeapDeviceDiscoveryService discoveryService = this.discoveryService;
618         if (discoveryService != null) {
619             discoveryService.processDeviceDefinitions(Arrays.asList(device));
620         }
621     }
622
623     @Override
624     public void handleMultipleDeviceDefinition(List<Device> deviceList) {
625         synchronized (zoneMapsLock) {
626             zoneToDevice.clear();
627             deviceToZone.clear();
628             for (Device device : deviceList) {
629                 Integer zoneid = device.getZone();
630                 Integer deviceid = device.getDevice();
631                 logger.trace("Found device: {} id: {} zone: {}", device.name, deviceid, zoneid);
632                 if (zoneid > 0 && deviceid > 0) {
633                     zoneToDevice.put(zoneid, deviceid);
634                     deviceToZone.put(deviceid, zoneid);
635                 }
636                 if (deviceid == 1 || device.isThisDevice) { // ID 1 is the bridge
637                     setBridgeProperties(device);
638                 }
639             }
640         }
641         deviceDataLoaded = true;
642         checkInitialized();
643
644         LeapDeviceDiscoveryService discoveryService = this.discoveryService;
645         if (discoveryService != null) {
646             discoveryService.processDeviceDefinitions(deviceList);
647         }
648     }
649
650     @Override
651     public void handleMultipleAreaDefinition(List<Area> areaList) {
652         LeapDeviceDiscoveryService discoveryService = this.discoveryService;
653         if (discoveryService != null) {
654             discoveryService.setAreas(areaList);
655         }
656     }
657
658     @Override
659     public void handleMultipleOccupancyGroupDefinition(List<OccupancyGroup> oGroupList) {
660         LeapDeviceDiscoveryService discoveryService = this.discoveryService;
661         if (discoveryService != null) {
662             discoveryService.setOccupancyGroups(oGroupList);
663         }
664     }
665
666     @Override
667     public void handleProjectDefinition(Project project) {
668         isRadioRA3 = LUTRON_RADIORA_3_PROJECT.equals(project.productType);
669
670         if (project.masterDeviceList.devices.length > 0 && project.masterDeviceList.devices[0].href != null) {
671             sendCommand(new LeapCommand(Request.getDevices(true)));
672         }
673
674         sendCommand(new LeapCommand(Request.getButtonGroups()));
675         queryDiscoveryData();
676
677         if (!isRadioRA3) {
678             logger.debug("Caseta Bridge Detected: {}", project.productType);
679         } else {
680             logger.debug("Detected a RadioRA 3 System: {}", project.productType);
681             sendCommand(new LeapCommand(Request.subscribeZoneStatus()));
682         }
683         sendCommand(new LeapCommand(Request.subscribeOccupancyGroupStatus()));
684     }
685
686     @Override
687     public void validMessageReceived(String communiqueType) {
688         reconnectTaskCancel(true); // Got a good message, so cancel reconnect task.
689     }
690
691     /**
692      * Set informational bridge properties from the Device entry for the hub/repeater
693      */
694     private void setBridgeProperties(Device device) {
695         if ((device.getDevice() == 1 && device.repeaterProperties != null) || (device.isThisDevice)) {
696             Map<String, String> properties = editProperties();
697             if (device.name != null) {
698                 properties.put(PROPERTY_PRODTYP, device.name);
699             }
700             if (device.modelNumber != null) {
701                 properties.put(Thing.PROPERTY_MODEL_ID, device.modelNumber);
702             }
703             if (device.serialNumber != null) {
704                 properties.put(Thing.PROPERTY_SERIAL_NUMBER, device.serialNumber);
705             }
706             if (device.firmwareImage != null && device.firmwareImage.firmware != null
707                     && device.firmwareImage.firmware.displayName != null) {
708                 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, device.firmwareImage.firmware.displayName);
709             }
710             updateProperties(properties);
711         }
712     }
713
714     /**
715      * Queue a LeapCommand for transmission by the sender thread.
716      */
717     public void sendCommand(@Nullable LeapCommand command) {
718         if (command != null) {
719             sendQueue.add(command);
720         }
721     }
722
723     /**
724      * Convert a LutronCommand into a LeapCommand and queue it for transmission by the sender thread.
725      */
726     @Override
727     public void sendCommand(LutronCommandNew command) {
728         logger.trace("Received request to send Lutron command: {}", command);
729         sendCommand(command.leapCommand(this, deviceToZone(command.getIntegrationId())));
730     }
731
732     /**
733      * Returns LEAP button number for given integrationID and component. Returns 0 if button number cannot be
734      * determined.
735      */
736     public int getButton(int integrationID, int component) {
737         synchronized (deviceButtonMapLock) {
738             if (deviceButtonMap != null) {
739                 List<Integer> buttonList = deviceButtonMap.get(integrationID);
740                 if (buttonList != null && component <= buttonList.size()) {
741                     return buttonList.get(component - 1);
742                 } else {
743                     logger.debug("Could not find button component {} for id {}", component, integrationID);
744                     return 0;
745                 }
746             } else {
747                 logger.debug("Device to button map not populated");
748                 return 0;
749             }
750         }
751     }
752
753     private @Nullable LutronHandler findThingHandler(@Nullable Integer integrationId) {
754         if (integrationId != null) {
755             return childHandlerMap.get(integrationId);
756         } else {
757             return null;
758         }
759     }
760
761     private @Nullable OGroupHandler findGroupHandler(int integrationId) {
762         return groupHandlerMap.get(integrationId);
763     }
764
765     private @Nullable Integer zoneToDevice(int zone) {
766         synchronized (zoneMapsLock) {
767             return zoneToDevice.get(zone);
768         }
769     }
770
771     private @Nullable Integer deviceToZone(@Nullable Integer device) {
772         if (device == null) {
773             return null;
774         }
775         synchronized (zoneMapsLock) {
776             return deviceToZone.get(device);
777         }
778     }
779
780     /**
781      * Executed by keepAliveJob. Sends a LEAP ping request and schedules a reconnect task.
782      */
783     private void sendKeepAlive() {
784         logger.trace("Sending keepalive query");
785         sendCommand(new LeapCommand(Request.ping()));
786         // Reconnect if no response is received within KEEPALIVE_TIMEOUT_SECONDS.
787         reconnectTaskSchedule();
788     }
789
790     /**
791      * Schedules the reconnect task keepAliveReconnectJob to execute in KEEPALIVE_TIMEOUT_SECONDS. This should be
792      * cancelled by calling reconnectTaskCancel() if a valid response is received from the bridge.
793      */
794     private void reconnectTaskSchedule() {
795         synchronized (keepAliveReconnectLock) {
796             keepAliveReconnectJob = scheduler.schedule(this::keepaliveTimeoutExpired, KEEPALIVE_TIMEOUT_SECONDS,
797                     TimeUnit.SECONDS);
798         }
799     }
800
801     /**
802      * Cancels the reconnect task keepAliveReconnectJob.
803      */
804     private void reconnectTaskCancel(boolean interrupt) {
805         synchronized (keepAliveReconnectLock) {
806             ScheduledFuture<?> keepAliveReconnectJob = this.keepAliveReconnectJob;
807             if (keepAliveReconnectJob != null) {
808                 logger.trace("Canceling scheduled reconnect job.");
809                 keepAliveReconnectJob.cancel(interrupt);
810                 this.keepAliveReconnectJob = null;
811             }
812         }
813     }
814
815     /**
816      * Executed by keepAliveReconnectJob if it is not cancelled by the LEAP message parser calling
817      * validMessageReceived() which in turn calls reconnectTaskCancel().
818      */
819     private void keepaliveTimeoutExpired() {
820         logger.debug("Keepalive response timeout expired. Initiating reconnect.");
821         reconnect();
822     }
823
824     @Override
825     public void handleCommand(ChannelUID channelUID, Command command) {
826         if (channelUID.getId().equals(CHANNEL_COMMAND)) {
827             if (command instanceof StringType) {
828                 sendCommand(new LeapCommand(command.toString()));
829             }
830         }
831     }
832
833     @Override
834     public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
835         if (childHandler instanceof OGroupHandler) {
836             // We need a different map for group things because the numbering is separate
837             OGroupHandler handler = (OGroupHandler) childHandler;
838             int groupId = handler.getIntegrationId();
839             groupHandlerMap.put(groupId, handler);
840             logger.trace("Registered group handler for ID {}", groupId);
841         } else {
842             LutronHandler handler = (LutronHandler) childHandler;
843             int intId = handler.getIntegrationId();
844             childHandlerMap.put(intId, handler);
845             logger.trace("Registered child handler for ID {}", intId);
846         }
847     }
848
849     @Override
850     public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
851         if (childHandler instanceof OGroupHandler) {
852             OGroupHandler handler = (OGroupHandler) childHandler;
853             int groupId = handler.getIntegrationId();
854             groupHandlerMap.remove(groupId);
855             logger.trace("Unregistered group handler for ID {}", groupId);
856         } else {
857             LutronHandler handler = (LutronHandler) childHandler;
858             int intId = handler.getIntegrationId();
859             childHandlerMap.remove(intId);
860             logger.trace("Unregistered child handler for ID {}", intId);
861         }
862     }
863
864     @Override
865     public void dispose() {
866         Future<?> asyncInitializeTask = this.asyncInitializeTask;
867         if (asyncInitializeTask != null && !asyncInitializeTask.isDone()) {
868             asyncInitializeTask.cancel(true); // Interrupt async init task if it isn't done yet
869         }
870         disconnect(true);
871     }
872 }