2 * Copyright (c) 2010-2020 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.discovery;
15 import static org.openhab.binding.lutron.internal.LutronBindingConstants.*;
17 import java.io.BufferedReader;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.InputStreamReader;
22 import java.nio.charset.StandardCharsets;
23 import java.nio.file.Files;
24 import java.util.ArrayList;
25 import java.util.HashMap;
26 import java.util.LinkedList;
27 import java.util.List;
29 import java.util.Stack;
30 import java.util.concurrent.ExecutionException;
31 import java.util.concurrent.Future;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34 import java.util.regex.Matcher;
35 import java.util.regex.Pattern;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.eclipse.jetty.client.HttpClient;
40 import org.eclipse.jetty.client.api.Response;
41 import org.eclipse.jetty.client.util.InputStreamResponseListener;
42 import org.eclipse.jetty.http.HttpHeader;
43 import org.eclipse.jetty.http.HttpMethod;
44 import org.eclipse.jetty.http.HttpStatus;
45 import org.openhab.binding.lutron.internal.LutronHandlerFactory;
46 import org.openhab.binding.lutron.internal.discovery.project.Area;
47 import org.openhab.binding.lutron.internal.discovery.project.Component;
48 import org.openhab.binding.lutron.internal.discovery.project.ComponentType;
49 import org.openhab.binding.lutron.internal.discovery.project.Device;
50 import org.openhab.binding.lutron.internal.discovery.project.DeviceGroup;
51 import org.openhab.binding.lutron.internal.discovery.project.DeviceNode;
52 import org.openhab.binding.lutron.internal.discovery.project.DeviceType;
53 import org.openhab.binding.lutron.internal.discovery.project.GreenMode;
54 import org.openhab.binding.lutron.internal.discovery.project.Output;
55 import org.openhab.binding.lutron.internal.discovery.project.OutputType;
56 import org.openhab.binding.lutron.internal.discovery.project.Project;
57 import org.openhab.binding.lutron.internal.discovery.project.Timeclock;
58 import org.openhab.binding.lutron.internal.handler.IPBridgeHandler;
59 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfig;
60 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfigGrafikEye;
61 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfigIntlSeetouch;
62 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfigPalladiom;
63 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfigPico;
64 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfigSeetouch;
65 import org.openhab.binding.lutron.internal.keypadconfig.KeypadConfigTabletopSeetouch;
66 import org.openhab.binding.lutron.internal.xml.DbXmlInfoReader;
67 import org.openhab.core.config.discovery.AbstractDiscoveryService;
68 import org.openhab.core.config.discovery.DiscoveryResult;
69 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
70 import org.openhab.core.thing.ThingTypeUID;
71 import org.openhab.core.thing.ThingUID;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
76 * The {@link LutronDeviceDiscoveryService} finds all devices paired with Lutron bridges by retrieving the
77 * configuration XML from them via HTTP.
79 * @author Allan Tong - Initial contribution
80 * @author Bob Adair - Added support for more output devices and keypads, VCRX, repeater virtual buttons,
81 * Timeclock, and Green Mode. Added option to read XML from file. Switched to jetty HTTP client for better
82 * exception handling. Added keypad model discovery.
85 public class LutronDeviceDiscoveryService extends AbstractDiscoveryService {
87 private static final int DECLARATION_MAX_LEN = 80;
88 private static final long HTTP_REQUEST_TIMEOUT = 60; // seconds
89 private static final int DISCOVERY_SERVICE_TIMEOUT = 90; // seconds
91 private static final String XML_DECLARATION_START = "<?xml";
92 private static final Pattern XML_DECLARATION_PATTERN = Pattern.compile(XML_DECLARATION_START,
93 Pattern.LITERAL | Pattern.CASE_INSENSITIVE);
95 private final Logger logger = LoggerFactory.getLogger(LutronDeviceDiscoveryService.class);
97 private IPBridgeHandler bridgeHandler;
98 private DbXmlInfoReader dbXmlInfoReader = new DbXmlInfoReader();
100 private final HttpClient httpClient;
102 private @Nullable Future<?> scanTask;
104 public LutronDeviceDiscoveryService(IPBridgeHandler bridgeHandler, HttpClient httpClient)
105 throws IllegalArgumentException {
106 super(LutronHandlerFactory.DISCOVERABLE_DEVICE_TYPES_UIDS, DISCOVERY_SERVICE_TIMEOUT);
108 this.bridgeHandler = bridgeHandler;
109 this.httpClient = httpClient;
113 protected synchronized void startScan() {
114 if (scanTask == null || scanTask.isDone()) {
115 scanTask = scheduler.submit(this::asyncDiscoveryTask);
119 private synchronized void asyncDiscoveryTask() {
121 readDeviceDatabase();
122 } catch (RuntimeException e) {
123 logger.warn("Runtime exception scanning for devices: {}", e.getMessage(), e);
125 if (scanListener != null) {
126 scanListener.onErrorOccurred(null); // null so it won't log a stack trace
131 private void readDeviceDatabase() {
132 Project project = null;
134 if (bridgeHandler == null || bridgeHandler.getIPBridgeConfig() == null) {
135 logger.debug("Unable to get bridge config. Exiting.");
138 String discFileName = bridgeHandler.getIPBridgeConfig().discoveryFile;
139 String address = "http://" + bridgeHandler.getIPBridgeConfig().ipAddress + "/DbXmlInfo.xml";
141 if (discFileName == null || discFileName.isEmpty()) {
142 // Read XML from bridge via HTTP
143 logger.trace("Sending http request for {}", address);
144 InputStreamResponseListener listener = new InputStreamResponseListener();
145 Response response = null;
147 // Use response stream instead of doing it the simple synchronous way because the response can be very large
148 httpClient.newRequest(address).method(HttpMethod.GET).timeout(HTTP_REQUEST_TIMEOUT, TimeUnit.SECONDS)
149 .header(HttpHeader.ACCEPT, "text/html").header(HttpHeader.ACCEPT_CHARSET, "utf-8").send(listener);
152 response = listener.get(HTTP_REQUEST_TIMEOUT, TimeUnit.SECONDS);
153 } catch (InterruptedException | TimeoutException | ExecutionException e) {
154 logger.info("Exception getting HTTP response: {}", e.getMessage());
157 if (response != null && response.getStatus() == HttpStatus.OK_200) {
158 logger.trace("Received good http response.");
160 try (InputStream responseStream = listener.getInputStream();
161 InputStreamReader xmlStreamReader = new InputStreamReader(responseStream,
162 StandardCharsets.UTF_8);
163 BufferedReader xmlBufReader = new BufferedReader(xmlStreamReader)) {
164 flushPrePrologLines(xmlBufReader);
166 project = dbXmlInfoReader.readFromXML(xmlBufReader);
167 if (project == null) {
168 logger.info("Failed to parse XML project file from {}", address);
170 } catch (IOException e) {
171 logger.info("IOException while processing XML project file: {}", e.getMessage());
174 if (response != null) {
175 logger.info("Received HTTP error response: {} {}", response.getStatus(), response.getReason());
177 logger.info("No response for HTTP request.");
181 // Read XML from file
182 File xmlFile = new File(discFileName);
184 try (BufferedReader xmlReader = Files.newBufferedReader(xmlFile.toPath(), StandardCharsets.UTF_8)) {
185 flushPrePrologLines(xmlReader);
187 project = dbXmlInfoReader.readFromXML(xmlReader);
188 if (project == null) {
189 logger.info("Could not process XML project file {}", discFileName);
191 } catch (IOException | SecurityException e) {
192 logger.info("Exception reading XML project file {} : {}", discFileName, e.getMessage());
196 if (project != null) {
197 Stack<String> locationContext = new Stack<>();
199 for (Area area : project.getAreas()) {
200 processArea(area, locationContext);
202 for (Timeclock timeclock : project.getTimeclocks()) {
203 processTimeclocks(timeclock, locationContext);
205 for (GreenMode greenMode : project.getGreenModes()) {
206 processGreenModes(greenMode, locationContext);
212 * Flushes any lines or characters before the start of the XML declaration in the supplied BufferedReader.
214 * @param xmlReader BufferedReader source of the XML document
215 * @throws IOException
217 private void flushPrePrologLines(BufferedReader xmlReader) throws IOException {
218 String inLine = null;
219 xmlReader.mark(DECLARATION_MAX_LEN);
220 boolean foundXmlDec = false;
222 while (!foundXmlDec && (inLine = xmlReader.readLine()) != null) {
223 Matcher matcher = XML_DECLARATION_PATTERN.matcher(inLine);
224 if (matcher.find()) {
227 if (matcher.start() > 0) {
228 logger.trace("Discarding {} characters.", matcher.start());
229 xmlReader.skip(matcher.start());
232 logger.trace("Discarding line: {}", inLine);
233 xmlReader.mark(DECLARATION_MAX_LEN);
238 private void processArea(Area area, Stack<String> context) {
239 context.push(area.getName());
241 for (DeviceNode deviceNode : area.getDeviceNodes()) {
242 if (deviceNode instanceof DeviceGroup) {
243 processDeviceGroup((DeviceGroup) deviceNode, context);
244 } else if (deviceNode instanceof Device) {
245 processDevice((Device) deviceNode, context);
249 for (Output output : area.getOutputs()) {
250 processOutput(output, context);
253 for (Area subarea : area.getAreas()) {
254 processArea(subarea, context);
260 private void processDeviceGroup(DeviceGroup deviceGroup, Stack<String> context) {
261 context.push(deviceGroup.getName());
263 for (Device device : deviceGroup.getDevices()) {
264 processDevice(device, context);
270 private void processDevice(Device device, Stack<String> context) {
271 List<Integer> buttons;
272 KeypadConfig kpConfig;
275 DeviceType type = device.getDeviceType();
278 String label = generateLabel(context, device.getName());
282 notifyDiscovery(THING_TYPE_OCCUPANCYSENSOR, device.getIntegrationId(), label);
285 case SEETOUCH_KEYPAD:
286 case HYBRID_SEETOUCH_KEYPAD:
287 kpConfig = new KeypadConfigSeetouch();
288 discoverKeypad(device, label, THING_TYPE_KEYPAD, "seeTouch Keypad", kpConfig);
291 case INTERNATIONAL_SEETOUCH_KEYPAD:
292 kpConfig = new KeypadConfigIntlSeetouch();
293 discoverKeypad(device, label, THING_TYPE_INTLKEYPAD, "International seeTouch Keypad", kpConfig);
296 case SEETOUCH_TABLETOP_KEYPAD:
297 kpConfig = new KeypadConfigTabletopSeetouch();
298 discoverKeypad(device, label, THING_TYPE_TTKEYPAD, "Tabletop seeTouch Keypad", kpConfig);
301 case PALLADIOM_KEYPAD:
302 kpConfig = new KeypadConfigPalladiom();
303 discoverKeypad(device, label, THING_TYPE_PALLADIOMKEYPAD, "Palladiom Keypad", kpConfig);
307 kpConfig = new KeypadConfigPico();
308 discoverKeypad(device, label, THING_TYPE_PICO, "Pico Keypad", kpConfig);
311 case VISOR_CONTROL_RECEIVER:
312 notifyDiscovery(THING_TYPE_VCRX, device.getIntegrationId(), label);
316 notifyDiscovery(THING_TYPE_WCI, device.getIntegrationId(), label);
320 notifyDiscovery(THING_TYPE_VIRTUALKEYPAD, device.getIntegrationId(), label);
323 case QS_IO_INTERFACE:
324 notifyDiscovery(THING_TYPE_QSIO, device.getIntegrationId(), label);
328 buttons = getComponentIdList(device.getComponents(), ComponentType.BUTTON);
329 // remove button IDs >= 300 which the handler does not recognize
330 List<Integer> buttonsCopy = new ArrayList<>(buttons);
331 for (Integer c : buttonsCopy) {
333 buttons.remove(Integer.valueOf(c));
336 kpConfig = new KeypadConfigGrafikEye();
337 kpModel = kpConfig.determineModelFromComponentIds(buttons);
338 if (kpModel == null) {
339 logger.info("Unable to determine model of GrafikEye Keypad {} with button IDs: {}",
340 device.getIntegrationId(), buttons);
341 notifyDiscovery(THING_TYPE_GRAFIKEYEKEYPAD, device.getIntegrationId(), label);
343 logger.debug("Found GrafikEye keypad {} model: {}", device.getIntegrationId(), kpModel);
344 notifyDiscovery(THING_TYPE_GRAFIKEYEKEYPAD, device.getIntegrationId(), label, "model", kpModel);
349 logger.warn("Unrecognized device type {}", device.getType());
353 private void discoverKeypad(Device device, String label, ThingTypeUID ttUid, String description,
354 KeypadConfig kpConfig) {
355 List<Integer> buttons = getComponentIdList(device.getComponents(), ComponentType.BUTTON);
356 String kpModel = kpConfig.determineModelFromComponentIds(buttons);
357 if (kpModel == null) {
358 logger.info("Unable to determine model of {} {} with button IDs: {}", description,
359 device.getIntegrationId(), buttons);
360 notifyDiscovery(ttUid, device.getIntegrationId(), label);
362 logger.debug("Found {} {} model: {}", description, device.getIntegrationId(), kpModel);
363 notifyDiscovery(ttUid, device.getIntegrationId(), label, "model", kpModel);
367 private List<Integer> getComponentIdList(List<Component> clist, ComponentType ctype) {
368 List<Integer> returnList = new LinkedList<>();
369 for (Component c : clist) {
370 if (c.getComponentType() == ctype) {
371 returnList.add(c.getComponentNumber());
377 private void processOutput(Output output, Stack<String> context) {
378 OutputType type = output.getOutputType();
381 String label = generateLabel(context, output.getName());
388 case ECO_SYSTEM_FLUORESCENT:
392 case CEILING_FAN_TYPE:
393 notifyDiscovery(THING_TYPE_DIMMER, output.getIntegrationId(), label);
400 notifyDiscovery(THING_TYPE_SWITCH, output.getIntegrationId(), label);
404 notifyDiscovery(THING_TYPE_CCO, output.getIntegrationId(), label, CCO_TYPE, CCO_TYPE_PULSED);
408 notifyDiscovery(THING_TYPE_CCO, output.getIntegrationId(), label, CCO_TYPE, CCO_TYPE_MAINTAINED);
413 notifyDiscovery(THING_TYPE_SHADE, output.getIntegrationId(), label);
417 notifyDiscovery(THING_TYPE_BLIND, output.getIntegrationId(), label, BLIND_TYPE_PARAMETER,
422 notifyDiscovery(THING_TYPE_BLIND, output.getIntegrationId(), label, BLIND_TYPE_PARAMETER,
423 BLIND_TYPE_VENETIAN);
427 logger.warn("Unrecognized output type {}", output.getType());
431 private void processTimeclocks(Timeclock timeclock, Stack<String> context) {
432 String label = generateLabel(context, timeclock.getName());
433 notifyDiscovery(THING_TYPE_TIMECLOCK, timeclock.getIntegrationId(), label);
436 private void processGreenModes(GreenMode greenmode, Stack<String> context) {
437 String label = generateLabel(context, greenmode.getName());
438 notifyDiscovery(THING_TYPE_GREENMODE, greenmode.getIntegrationId(), label);
441 private void notifyDiscovery(ThingTypeUID thingTypeUID, @Nullable Integer integrationId, String label,
442 @Nullable String propName, @Nullable Object propValue) {
443 if (integrationId == null) {
444 logger.info("Discovered {} with no integration ID", label);
449 ThingUID bridgeUID = this.bridgeHandler.getThing().getUID();
450 ThingUID uid = new ThingUID(thingTypeUID, bridgeUID, integrationId.toString());
452 Map<String, Object> properties = new HashMap<>();
454 properties.put(INTEGRATION_ID, integrationId);
456 if (propName != null && propValue != null) {
457 properties.put(propName, propValue);
460 DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID).withLabel(label)
461 .withProperties(properties).withRepresentationProperty(INTEGRATION_ID).build();
463 thingDiscovered(result);
465 logger.debug("Discovered {}", uid);
468 private void notifyDiscovery(ThingTypeUID thingTypeUID, Integer integrationId, String label) {
469 notifyDiscovery(thingTypeUID, integrationId, label, null, null);
472 private String generateLabel(Stack<String> context, String deviceName) {
473 return String.join(" ", context) + " " + deviceName;