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