2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.lutron.internal.handler;
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;
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;
49 * Handler responsible for communicating with the main Lutron control hub.
51 * @author Allan Tong - Initial contribution
52 * @author Bob Adair - Added reconnect and heartbeat config parameters, moved discovery service registration to
53 * LutronHandlerFactory
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");
59 private static final String DB_UPDATE_DATE_FORMAT = "MM/dd/yyyy HH:mm:ss";
61 private static final Integer SYSTEM_DBEXPORTDATETIME = 10;
63 private static final int MAX_LOGIN_ATTEMPTS = 2;
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>)";
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;
76 private final Logger logger = LoggerFactory.getLogger(IPBridgeHandler.class);
78 private IPBridgeConfig config;
79 private int reconnectInterval;
80 private int heartbeatInterval;
81 private int sendDelay;
83 private TelnetSession session;
84 private BlockingQueue<LutronCommandNew> sendQueue = new LinkedBlockingQueue<>();
86 private Thread messageSender;
87 private ScheduledFuture<?> keepAlive;
88 private ScheduledFuture<?> keepAliveReconnect;
89 private ScheduledFuture<?> connectRetryJob;
91 private Date lastDbUpdateDate;
92 private LutronDeviceDiscoveryService discoveryService;
94 private final AtomicBoolean requireSysvarMonitoring = new AtomicBoolean(false);
96 public void setDiscoveryService(LutronDeviceDiscoveryService discoveryService) {
97 this.discoveryService = discoveryService;
100 public class LutronSafemodeException extends Exception {
101 private static final long serialVersionUID = 1L;
103 public LutronSafemodeException(String message) {
108 public IPBridgeConfig getIPBridgeConfig() {
112 public IPBridgeHandler(Bridge bridge) {
115 this.session = new TelnetSession();
117 this.session.addListener(new TelnetSessionListener() {
119 public void inputAvailable() {
124 public void error(IOException exception) {
130 public void handleCommand(ChannelUID channelUID, Command command) {
134 public void initialize() {
135 this.config = getThing().getConfiguration().as(IPBridgeConfig.class);
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;
142 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Connecting");
143 scheduler.submit(this::connect); // start the async connect task
147 private boolean validConfiguration(IPBridgeConfig config) {
148 if (config == null) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridge configuration missing");
154 String ipAddress = config.ipAddress;
155 if (ipAddress == null || ipAddress.isEmpty()) {
156 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "bridge address not specified");
164 private void scheduleConnectRetry(long waitMinutes) {
165 logger.debug("Scheduling connection retry in {} minutes", waitMinutes);
166 connectRetryJob = scheduler.schedule(this::connect, waitMinutes, TimeUnit.MINUTES);
169 private synchronized void connect() {
170 if (this.session.isConnected()) {
174 logger.debug("Connecting to bridge at {}", config.ipAddress);
177 if (!login(config)) {
178 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "invalid username/password");
182 } catch (LutronSafemodeException e) {
183 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "main repeater is in safe mode");
185 scheduleConnectRetry(reconnectInterval); // Possibly a temporary problem. Try again later.
188 } catch (IOException e) {
189 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
191 scheduleConnectRetry(reconnectInterval); // Possibly a temporary problem. Try again later.
194 } catch (InterruptedException e) {
195 Thread.currentThread().interrupt();
197 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.HANDLER_INITIALIZING_ERROR, "login interrupted");
203 updateStatus(ThingStatus.ONLINE);
206 sendCommand(new LIPCommand(TargetType.BRIDGE, LutronOperation.EXECUTE, LutronCommandType.MONITORING, null,
207 Monitoring.PROMPT, Monitoring.ACTION_DISABLE));
210 if (requireSysvarMonitoring.get()) {
211 setSysvarMonitoring(true);
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));
219 messageSender = new Thread(this::sendCommandsThread, "Lutron sender");
220 messageSender.start();
222 logger.debug("Starting keepAlive job with interval {}", heartbeatInterval);
223 keepAlive = scheduler.scheduleWithFixedDelay(this::sendKeepAlive, heartbeatInterval, heartbeatInterval,
227 private void sendCommandsThread() {
229 while (!Thread.currentThread().isInterrupted()) {
230 LutronCommandNew command = sendQueue.take();
232 logger.debug("Sending command {}", command);
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);
240 sendQueue.add(command); // Requeue command
244 // reconnect() will start a new thread; terminate this one
248 Thread.sleep(sendDelay); // introduce delay to throttle send rate
251 } catch (InterruptedException e) {
252 Thread.currentThread().interrupt();
256 private synchronized void disconnect() {
257 logger.debug("Disconnecting from bridge");
259 if (connectRetryJob != null) {
260 connectRetryJob.cancel(true);
263 if (this.keepAlive != null) {
264 this.keepAlive.cancel(true);
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);
273 if (messageSender != null && messageSender.isAlive()) {
274 messageSender.interrupt();
278 this.session.close();
279 } catch (IOException e) {
280 logger.warn("Error disconnecting: {}", e.getMessage());
284 private synchronized void reconnect() {
285 logger.debug("Keepalive timeout, attempting to reconnect to the bridge");
287 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.DUTY_CYCLE);
292 private boolean login(IPBridgeConfig config) throws IOException, InterruptedException, LutronSafemodeException {
293 this.session.open(config.ipAddress);
294 this.session.waitFor("login:");
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);
302 MatchResult matchResult = this.session.waitFor(LOGIN_MATCH_REGEX);
304 if (PROMPT_GNET.equals(matchResult.group()) || PROMPT_QNET.equals(matchResult.group())) {
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");
312 logger.debug("got another login prompt, logging in again");
313 // we already got the login prompt so go straight to sending user
320 public void sendCommand(LutronCommandNew command) {
321 sendQueue.add(command);
324 private LutronHandler findThingHandler(int integrationId) {
325 for (Thing thing : getThing().getThings()) {
326 if (thing.getHandler() instanceof LutronHandler) {
327 LutronHandler handler = (LutronHandler) thing.getHandler();
330 if (handler != null && handler.getIntegrationId() == integrationId) {
333 } catch (IllegalStateException e) {
334 logger.trace("Handler for id {} not initialized", integrationId);
342 private void parseUpdates() {
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.
352 logger.debug("Received message {}", line);
354 // System is alive, cancel reconnect task.
355 if (this.keepAliveReconnect != null) {
356 this.keepAliveReconnect.cancel(true);
359 Matcher matcher = RESPONSE_REGEX.matcher(line);
360 boolean responseMatched = matcher.find();
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) {
371 logger.debug("Cleaned response line: {}", scrubbedLine);
376 if (!responseMatched) {
377 logger.debug("Ignoring message {}", line);
380 // We have a good response message
381 LutronCommandType type = LutronCommandType.valueOf(matcher.group(1));
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));
391 Integer integrationId;
394 integrationId = Integer.valueOf(matcher.group(2));
395 } catch (NumberFormatException e1) {
396 logger.warn("Integer conversion error parsing update: {}", line);
399 paramString = matcher.group(3);
401 // Now dispatch update to the proper thing handler
402 LutronHandler handler = findThingHandler(integrationId);
404 if (handler != null) {
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);
413 logger.debug("No thing configured for integration ID {}", integrationId);
419 private void sendKeepAlive() {
420 logger.debug("Scheduling keepalive reconnect job");
422 // Reconnect if no response is received within 30 seconds.
423 keepAliveReconnect = scheduler.schedule(this::reconnect, KEEPALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
425 logger.trace("Sending keepalive query");
426 sendCommand(new LIPCommand(TargetType.BRIDGE, LutronOperation.QUERY, LutronCommandType.SYSTEM, null,
427 SYSTEM_DBEXPORTDATETIME));
430 private void setDbUpdateDate(String dateString, String timeString) {
432 Date date = new SimpleDateFormat(DB_UPDATE_DATE_FORMAT).parse(dateString + " " + timeString);
434 if (this.lastDbUpdateDate == null || date.after(this.lastDbUpdateDate)) {
437 this.lastDbUpdateDate = date;
439 } catch (ParseException e) {
440 logger.warn("Failed to parse DB update date {} {}", dateString, timeString);
444 private void scanForDevices() {
446 if (discoveryService != null) {
447 logger.debug("Initiating discovery scan for devices");
448 discoveryService.startScan(null);
450 logger.debug("Unable to initiate discovery because discoveryService is null");
452 } catch (Exception e) {
453 logger.warn("Error scanning for paired devices: {}", e.getMessage(), e);
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));
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));
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);
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);
486 if (!validConfig || needsReconnect) {
491 this.config = newConfig;
493 if (needsReconnect) {
499 public void dispose() {