]> git.basschouten.com Git - openhab-addons.git/blob
61c6cf4e9bae7f64d3f2f3954c683b5809b74c28
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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 java.io.IOException;
16 import java.text.ParseException;
17 import java.text.SimpleDateFormat;
18 import java.util.Date;
19 import java.util.concurrent.BlockingQueue;
20 import java.util.concurrent.LinkedBlockingQueue;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.concurrent.atomic.AtomicBoolean;
24 import java.util.regex.MatchResult;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
27
28 import org.openhab.binding.lutron.internal.config.IPBridgeConfig;
29 import org.openhab.binding.lutron.internal.discovery.LutronDeviceDiscoveryService;
30 import org.openhab.binding.lutron.internal.net.TelnetSession;
31 import org.openhab.binding.lutron.internal.net.TelnetSessionListener;
32 import org.openhab.binding.lutron.internal.protocol.LIPCommand;
33 import org.openhab.binding.lutron.internal.protocol.LutronCommandNew;
34 import org.openhab.binding.lutron.internal.protocol.lip.LutronCommandType;
35 import org.openhab.binding.lutron.internal.protocol.lip.LutronOperation;
36 import org.openhab.binding.lutron.internal.protocol.lip.TargetType;
37 import org.openhab.core.thing.Bridge;
38 import org.openhab.core.thing.ChannelUID;
39 import org.openhab.core.thing.Thing;
40 import org.openhab.core.thing.ThingStatus;
41 import org.openhab.core.thing.ThingStatusDetail;
42 import org.openhab.core.thing.binding.ThingHandler;
43 import org.openhab.core.types.Command;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
46
47 /**
48  * Handler responsible for communicating with the main Lutron control hub.
49  *
50  * @author Allan Tong - Initial contribution
51  * @author Bob Adair - Added reconnect and heartbeat config parameters, moved discovery service registration to
52  *         LutronHandlerFactory
53  */
54 public class IPBridgeHandler extends LutronBridgeHandler {
55     private static final Pattern RESPONSE_REGEX = Pattern
56             .compile("~(OUTPUT|DEVICE|SYSTEM|TIMECLOCK|MODE|SYSVAR|GROUP),([0-9\\.:/]+),([0-9,\\.:/]*)\\Z");
57
58     private static final String DB_UPDATE_DATE_FORMAT = "MM/dd/yyyy HH:mm:ss";
59
60     private static final Integer MONITOR_PROMPT = 12;
61     private static final Integer MONITOR_SYSVAR = 10;
62     private static final Integer MONITOR_ENABLE = 1;
63     private static final Integer MONITOR_DISABLE = 2;
64
65     private static final Integer SYSTEM_DBEXPORTDATETIME = 10;
66
67     private static final int MAX_LOGIN_ATTEMPTS = 2;
68
69     private static final String PROMPT_GNET = "GNET>";
70     private static final String PROMPT_QNET = "QNET>";
71     private static final String PROMPT_SAFE = "SAFE>";
72     private static final String LOGIN_MATCH_REGEX = "(login:|[GQ]NET>|SAFE>)";
73
74     private static final String DEFAULT_USER = "lutron";
75     private static final String DEFAULT_PASSWORD = "integration";
76     private static final int DEFAULT_RECONNECT_MINUTES = 5;
77     private static final int DEFAULT_HEARTBEAT_MINUTES = 5;
78     private static final long KEEPALIVE_TIMEOUT_SECONDS = 30;
79
80     private final Logger logger = LoggerFactory.getLogger(IPBridgeHandler.class);
81
82     private IPBridgeConfig config;
83     private int reconnectInterval;
84     private int heartbeatInterval;
85     private int sendDelay;
86
87     private TelnetSession session;
88     private BlockingQueue<LutronCommandNew> sendQueue = new LinkedBlockingQueue<>();
89
90     private Thread messageSender;
91     private ScheduledFuture<?> keepAlive;
92     private ScheduledFuture<?> keepAliveReconnect;
93     private ScheduledFuture<?> connectRetryJob;
94
95     private Date lastDbUpdateDate;
96     private LutronDeviceDiscoveryService discoveryService;
97
98     private final AtomicBoolean requireSysvarMonitoring = new AtomicBoolean(false);
99
100     public void setDiscoveryService(LutronDeviceDiscoveryService discoveryService) {
101         this.discoveryService = discoveryService;
102     }
103
104     public class LutronSafemodeException extends Exception {
105         private static final long serialVersionUID = 1L;
106
107         public LutronSafemodeException(String message) {
108             super(message);
109         }
110     }
111
112     public IPBridgeConfig getIPBridgeConfig() {
113         return config;
114     }
115
116     public IPBridgeHandler(Bridge bridge) {
117         super(bridge);
118
119         this.session = new TelnetSession();
120
121         this.session.addListener(new TelnetSessionListener() {
122             @Override
123             public void inputAvailable() {
124                 parseUpdates();
125             }
126
127             @Override
128             public void error(IOException exception) {
129             }
130         });
131     }
132
133     @Override
134     public void handleCommand(ChannelUID channelUID, Command command) {
135     }
136
137     @Override
138     public void initialize() {
139         this.config = getThing().getConfiguration().as(IPBridgeConfig.class);
140
141         if (validConfiguration(this.config)) {
142             reconnectInterval = (config.reconnect > 0) ? config.reconnect : DEFAULT_RECONNECT_MINUTES;
143             heartbeatInterval = (config.heartbeat > 0) ? config.heartbeat : DEFAULT_HEARTBEAT_MINUTES;
144             sendDelay = (config.delay < 0) ? 0 : config.delay;
145
146             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Connecting");
147             scheduler.submit(this::connect); // start the async connect task
148         }
149     }
150
151     private boolean validConfiguration(IPBridgeConfig config) {
152         if (config == null) {
153             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridge configuration missing");
154
155             return false;
156         }
157
158         String ipAddress = config.ipAddress;
159         if (ipAddress == null || ipAddress.isEmpty()) {
160             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridge address not specified");
161
162             return false;
163         }
164
165         return true;
166     }
167
168     private void scheduleConnectRetry(long waitMinutes) {
169         logger.debug("Scheduling connection retry in {} minutes", waitMinutes);
170         connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
171     }
172
173     private synchronized void connect() {
174         if (this.session.isConnected()) {
175             return;
176         }
177
178         logger.debug("Connecting to bridge at {}", config.ipAddress);
179
180         try {
181             if (!login(config)) {
182                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "invalid username/password");
183
184                 return;
185             }
186         } catch (LutronSafemodeException e) {
187             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "main repeater is in safe mode");
188             disconnect();
189             scheduleConnectRetry(reconnectInterval); // Possibly a temporary problem. Try again later.
190
191             return;
192         } catch (IOException e) {
193             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
194             disconnect();
195             scheduleConnectRetry(reconnectInterval); // Possibly a temporary problem. Try again later.
196
197             return;
198         } catch (InterruptedException e) {
199             Thread.currentThread().interrupt();
200
201             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "login interrupted");
202             disconnect();
203
204             return;
205         }
206
207         updateStatus(ThingStatus.ONLINE);
208
209         // Disable prompts
210         sendCommand(new LIPCommand(TargetType.BRIDGE, LutronOperation.EXECUTE, LutronCommandType.MONITORING, null,
211                 MONITOR_PROMPT, MONITOR_DISABLE));
212
213         if (requireSysvarMonitoring.get()) {
214             setSysvarMonitoring(true);
215         }
216
217         // Check the time device database was last updated. On the initial connect, this will trigger
218         // a scan for paired devices.
219         sendCommand(new LIPCommand(TargetType.BRIDGE, LutronOperation.QUERY, LutronCommandType.SYSTEM, null,
220                 SYSTEM_DBEXPORTDATETIME));
221
222         messageSender = new Thread(this::sendCommandsThread, "Lutron sender");
223         messageSender.start();
224
225         logger.debug("Starting keepAlive job with interval {}", heartbeatInterval);
226         keepAlive = scheduler.scheduleWithFixedDelay(this::sendKeepAlive, heartbeatInterval, heartbeatInterval,
227                 TimeUnit.MINUTES);
228     }
229
230     private void sendCommandsThread() {
231         try {
232             while (!Thread.currentThread().isInterrupted()) {
233                 LutronCommandNew command = sendQueue.take();
234
235                 logger.debug("Sending command {}", command);
236
237                 try {
238                     session.writeLine(command.toString());
239                 } catch (IOException e) {
240                     logger.warn("Communication error, will try to reconnect. Error: {}", e.getMessage());
241                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
242
243                     sendQueue.add(command); // Requeue command
244
245                     reconnect();
246
247                     // reconnect() will start a new thread; terminate this one
248                     break;
249                 }
250                 if (sendDelay > 0) {
251                     Thread.sleep(sendDelay); // introduce delay to throttle send rate
252                 }
253             }
254         } catch (InterruptedException e) {
255             Thread.currentThread().interrupt();
256         }
257     }
258
259     private synchronized void disconnect() {
260         logger.debug("Disconnecting from bridge");
261
262         if (connectRetryJob != null) {
263             connectRetryJob.cancel(true);
264         }
265
266         if (this.keepAlive != null) {
267             this.keepAlive.cancel(true);
268         }
269
270         if (this.keepAliveReconnect != null) {
271             // This method can be called from the keepAliveReconnect thread. Make sure
272             // we don't interrupt ourselves, as that may prevent the reconnection attempt.
273             this.keepAliveReconnect.cancel(false);
274         }
275
276         if (messageSender != null && messageSender.isAlive()) {
277             messageSender.interrupt();
278         }
279
280         try {
281             this.session.close();
282         } catch (IOException e) {
283             logger.warn("Error disconnecting: {}", e.getMessage());
284         }
285     }
286
287     private synchronized void reconnect() {
288         logger.debug("Keepalive timeout, attempting to reconnect to the bridge");
289
290         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.DUTY_CYCLE);
291         disconnect();
292         connect();
293     }
294
295     private boolean login(IPBridgeConfig config) throws IOException, InterruptedException, LutronSafemodeException {
296         this.session.open(config.ipAddress);
297         this.session.waitFor("login:");
298
299         // Sometimes the Lutron Smart Bridge Pro will request login more than once.
300         for (int attempt = 0; attempt < MAX_LOGIN_ATTEMPTS; attempt++) {
301             this.session.writeLine(config.user != null ? config.user : DEFAULT_USER);
302             this.session.waitFor("password:");
303             this.session.writeLine(config.password != null ? config.password : DEFAULT_PASSWORD);
304
305             MatchResult matchResult = this.session.waitFor(LOGIN_MATCH_REGEX);
306
307             if (PROMPT_GNET.equals(matchResult.group()) || PROMPT_QNET.equals(matchResult.group())) {
308                 return true;
309             } else if (PROMPT_SAFE.equals(matchResult.group())) {
310                 logger.warn("Lutron repeater is in safe mode. Unable to connect.");
311                 throw new LutronSafemodeException("Lutron repeater in safe mode");
312             }
313
314             else {
315                 logger.debug("got another login prompt, logging in again");
316                 // we already got the login prompt so go straight to sending user
317             }
318         }
319         return false;
320     }
321
322     @Override
323     public void sendCommand(LutronCommandNew command) {
324         sendQueue.add(command);
325     }
326
327     private LutronHandler findThingHandler(int integrationId) {
328         for (Thing thing : getThing().getThings()) {
329             if (thing.getHandler() instanceof LutronHandler) {
330                 LutronHandler handler = (LutronHandler) thing.getHandler();
331
332                 try {
333                     if (handler != null && handler.getIntegrationId() == integrationId) {
334                         return handler;
335                     }
336                 } catch (IllegalStateException e) {
337                     logger.trace("Handler for id {} not initialized", integrationId);
338                 }
339             }
340         }
341
342         return null;
343     }
344
345     private void parseUpdates() {
346         String paramString;
347         String scrubbedLine;
348
349         for (String line : this.session.readLines()) {
350             if (line.trim().equals("")) {
351                 // Sometimes we get an empty line (possibly only when prompts are disabled). Ignore them.
352                 continue;
353             }
354
355             logger.debug("Received message {}", line);
356
357             // System is alive, cancel reconnect task.
358             if (this.keepAliveReconnect != null) {
359                 this.keepAliveReconnect.cancel(true);
360             }
361
362             Matcher matcher = RESPONSE_REGEX.matcher(line);
363             boolean responseMatched = matcher.find();
364
365             if (!responseMatched) {
366                 // In some cases with Caseta a CLI prompt may be embedded within a received response line.
367                 if (line.contains("NET>")) {
368                     // Try to remove it and re-attempt the regex match.
369                     scrubbedLine = line.replaceAll("[GQ]NET> ", "");
370                     matcher = RESPONSE_REGEX.matcher(scrubbedLine);
371                     responseMatched = matcher.find();
372                     if (responseMatched) {
373                         line = scrubbedLine;
374                         logger.debug("Cleaned response line: {}", scrubbedLine);
375                     }
376                 }
377             }
378
379             if (!responseMatched) {
380                 logger.debug("Ignoring message {}", line);
381                 continue;
382             } else {
383                 // We have a good response message
384                 LutronCommandType type = LutronCommandType.valueOf(matcher.group(1));
385
386                 if (type == LutronCommandType.SYSTEM) {
387                     // SYSTEM messages are assumed to be a response to the SYSTEM_DBEXPORTDATETIME
388                     // query. The response returns the last time the device database was updated.
389                     setDbUpdateDate(matcher.group(2), matcher.group(3));
390
391                     continue;
392                 }
393
394                 Integer integrationId;
395
396                 try {
397                     integrationId = Integer.valueOf(matcher.group(2));
398                 } catch (NumberFormatException e1) {
399                     logger.warn("Integer conversion error parsing update: {}", line);
400                     continue;
401                 }
402                 paramString = matcher.group(3);
403
404                 // Now dispatch update to the proper thing handler
405                 LutronHandler handler = findThingHandler(integrationId);
406
407                 if (handler != null) {
408                     try {
409                         handler.handleUpdate(type, paramString.split(","));
410                     } catch (NumberFormatException e) {
411                         logger.warn("Number format exception parsing update: {}", line);
412                     } catch (RuntimeException e) {
413                         logger.warn("Runtime exception while processing update: {}", line, e);
414                     }
415                 } else {
416                     logger.debug("No thing configured for integration ID {}", integrationId);
417                 }
418             }
419         }
420     }
421
422     private void sendKeepAlive() {
423         logger.debug("Scheduling keepalive reconnect job");
424
425         // Reconnect if no response is received within 30 seconds.
426         keepAliveReconnect = scheduler.schedule(this::reconnect, KEEPALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
427
428         logger.trace("Sending keepalive query");
429         sendCommand(new LIPCommand(TargetType.BRIDGE, LutronOperation.QUERY, LutronCommandType.SYSTEM, null,
430                 SYSTEM_DBEXPORTDATETIME));
431     }
432
433     private void setDbUpdateDate(String dateString, String timeString) {
434         try {
435             Date date = new SimpleDateFormat(DB_UPDATE_DATE_FORMAT).parse(dateString + " " + timeString);
436
437             if (this.lastDbUpdateDate == null || date.after(this.lastDbUpdateDate)) {
438                 scanForDevices();
439
440                 this.lastDbUpdateDate = date;
441             }
442         } catch (ParseException e) {
443             logger.warn("Failed to parse DB update date {} {}", dateString, timeString);
444         }
445     }
446
447     private void scanForDevices() {
448         try {
449             if (discoveryService != null) {
450                 logger.debug("Initiating discovery scan for devices");
451                 discoveryService.startScan(null);
452             } else {
453                 logger.debug("Unable to initiate discovery because discoveryService is null");
454             }
455         } catch (Exception e) {
456             logger.warn("Error scanning for paired devices: {}", e.getMessage(), e);
457         }
458     }
459
460     private void setSysvarMonitoring(boolean enable) {
461         Integer setting = (enable) ? MONITOR_ENABLE : MONITOR_DISABLE;
462         sendCommand(new LIPCommand(TargetType.BRIDGE, LutronOperation.EXECUTE, LutronCommandType.MONITORING, null,
463                 MONITOR_SYSVAR, setting));
464     }
465
466     @Override
467     public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) {
468         // enable sysvar monitoring the first time a sysvar child thing initializes
469         if (childHandler instanceof SysvarHandler) {
470             if (requireSysvarMonitoring.compareAndSet(false, true)) {
471                 setSysvarMonitoring(true);
472             }
473         }
474     }
475
476     @Override
477     public void thingUpdated(Thing thing) {
478         IPBridgeConfig newConfig = thing.getConfiguration().as(IPBridgeConfig.class);
479         boolean validConfig = validConfiguration(newConfig);
480         boolean needsReconnect = validConfig && !this.config.sameConnectionParameters(newConfig);
481
482         if (!validConfig || needsReconnect) {
483             dispose();
484         }
485
486         this.thing = thing;
487         this.config = newConfig;
488
489         if (needsReconnect) {
490             initialize();
491         }
492     }
493
494     @Override
495     public void dispose() {
496         disconnect();
497     }
498 }