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