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.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 final 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 Future<?> scanTask = this.scanTask;
115 if (scanTask == null || scanTask.isDone()) {
116 this.scanTask = scheduler.submit(this::asyncDiscoveryTask);
120 private synchronized void asyncDiscoveryTask() {
122 readDeviceDatabase();
123 } catch (RuntimeException e) {
124 logger.warn("Runtime exception scanning for devices: {}", e.getMessage(), e);
126 if (scanListener != null) {
127 scanListener.onErrorOccurred(null); // null so it won't log a stack trace
132 private void readDeviceDatabase() {
133 Project project = null;
135 if (bridgeHandler.getIPBridgeConfig() == null) {
136 logger.debug("Unable to get bridge config. Exiting.");
139 String discFileName = bridgeHandler.getIPBridgeConfig().discoveryFile;
140 String address = "http://" + bridgeHandler.getIPBridgeConfig().ipAddress + "/DbXmlInfo.xml";
142 if (discFileName == null || discFileName.isEmpty()) {
143 // Read XML from bridge via HTTP
144 logger.trace("Sending http request for {}", address);
145 InputStreamResponseListener listener = new InputStreamResponseListener();
146 Response response = null;
148 // Use response stream instead of doing it the simple synchronous way because the response can be very large
149 httpClient.newRequest(address).method(HttpMethod.GET).timeout(HTTP_REQUEST_TIMEOUT, TimeUnit.SECONDS)
150 .header(HttpHeader.ACCEPT, "text/html").header(HttpHeader.ACCEPT_CHARSET, "utf-8").send(listener);
153 response = listener.get(HTTP_REQUEST_TIMEOUT, TimeUnit.SECONDS);
154 } catch (InterruptedException | TimeoutException | ExecutionException e) {
155 logger.info("Exception getting HTTP response: {}", e.getMessage());
158 if (response != null && response.getStatus() == HttpStatus.OK_200) {
159 logger.trace("Received good http response.");
161 try (InputStream responseStream = listener.getInputStream();
162 InputStreamReader xmlStreamReader = new InputStreamReader(responseStream,
163 StandardCharsets.UTF_8);
164 BufferedReader xmlBufReader = new BufferedReader(xmlStreamReader)) {
165 flushPrePrologLines(xmlBufReader);
167 project = dbXmlInfoReader.readFromXML(xmlBufReader);
168 if (project == null) {
169 logger.info("Failed to parse XML project file from {}", address);
171 } catch (IOException e) {
172 logger.info("IOException while processing XML project file: {}", e.getMessage());
175 if (response != null) {
176 logger.info("Received HTTP error response: {} {}", response.getStatus(), response.getReason());
178 logger.info("No response for HTTP request.");
182 // Read XML from file
183 File xmlFile = new File(discFileName);
185 try (BufferedReader xmlReader = Files.newBufferedReader(xmlFile.toPath(), StandardCharsets.UTF_8)) {
186 flushPrePrologLines(xmlReader);
188 project = dbXmlInfoReader.readFromXML(xmlReader);
189 if (project == null) {
190 logger.info("Could not process XML project file {}", discFileName);
192 } catch (IOException | SecurityException e) {
193 logger.info("Exception reading XML project file {} : {}", discFileName, e.getMessage());
197 if (project != null) {
198 Stack<String> locationContext = new Stack<>();
200 for (Area area : project.getAreas()) {
201 processArea(area, locationContext);
203 for (Timeclock timeclock : project.getTimeclocks()) {
204 processTimeclocks(timeclock, locationContext);
206 for (GreenMode greenMode : project.getGreenModes()) {
207 processGreenModes(greenMode, locationContext);
213 * Flushes any lines or characters before the start of the XML declaration in the supplied BufferedReader.
215 * @param xmlReader BufferedReader source of the XML document
216 * @throws IOException
218 private void flushPrePrologLines(BufferedReader xmlReader) throws IOException {
219 String inLine = null;
220 xmlReader.mark(DECLARATION_MAX_LEN);
221 boolean foundXmlDec = false;
223 while (!foundXmlDec && (inLine = xmlReader.readLine()) != null) {
224 Matcher matcher = XML_DECLARATION_PATTERN.matcher(inLine);
225 if (matcher.find()) {
228 if (matcher.start() > 0) {
229 logger.trace("Discarding {} characters.", matcher.start());
230 xmlReader.skip(matcher.start());
233 logger.trace("Discarding line: {}", inLine);
234 xmlReader.mark(DECLARATION_MAX_LEN);
239 private void processArea(Area area, Stack<String> context) {
240 context.push(area.getName());
242 for (DeviceNode deviceNode : area.getDeviceNodes()) {
243 if (deviceNode instanceof DeviceGroup group) {
244 processDeviceGroup(area, group, context);
245 } else if (deviceNode instanceof Device device) {
246 processDevice(area, device, context);
250 for (Output output : area.getOutputs()) {
251 processOutput(output, context);
254 for (Area subarea : area.getAreas()) {
255 processArea(subarea, context);
261 private void processDeviceGroup(Area area, DeviceGroup deviceGroup, Stack<String> context) {
262 context.push(deviceGroup.getName());
264 for (Device device : deviceGroup.getDevices()) {
265 processDevice(area, device, context);
271 private void processDevice(Area area, Device device, Stack<String> context) {
272 List<Integer> buttons;
273 KeypadConfig kpConfig;
276 DeviceType type = device.getDeviceType();
279 String label = generateLabel(context, device.getName());
283 notifyDiscovery(THING_TYPE_OCCUPANCYSENSOR, device.getIntegrationId(), label);
284 notifyDiscovery(THING_TYPE_OGROUP, area.getIntegrationId(), area.getName());
287 case SEETOUCH_KEYPAD:
288 case HYBRID_SEETOUCH_KEYPAD:
289 kpConfig = new KeypadConfigSeetouch();
290 discoverKeypad(device, label, THING_TYPE_KEYPAD, "seeTouch Keypad", kpConfig);
293 case INTERNATIONAL_SEETOUCH_KEYPAD:
294 kpConfig = new KeypadConfigIntlSeetouch();
295 discoverKeypad(device, label, THING_TYPE_INTLKEYPAD, "International seeTouch Keypad", kpConfig);
298 case SEETOUCH_TABLETOP_KEYPAD:
299 kpConfig = new KeypadConfigTabletopSeetouch();
300 discoverKeypad(device, label, THING_TYPE_TTKEYPAD, "Tabletop seeTouch Keypad", kpConfig);
303 case PALLADIOM_KEYPAD:
304 kpConfig = new KeypadConfigPalladiom();
305 discoverKeypad(device, label, THING_TYPE_PALLADIOMKEYPAD, "Palladiom Keypad", kpConfig);
309 kpConfig = new KeypadConfigPico();
310 discoverKeypad(device, label, THING_TYPE_PICO, "Pico Keypad", kpConfig);
313 case VISOR_CONTROL_RECEIVER:
314 notifyDiscovery(THING_TYPE_VCRX, device.getIntegrationId(), label);
318 notifyDiscovery(THING_TYPE_WCI, device.getIntegrationId(), label);
322 notifyDiscovery(THING_TYPE_VIRTUALKEYPAD, device.getIntegrationId(), label);
325 case QS_IO_INTERFACE:
326 notifyDiscovery(THING_TYPE_QSIO, device.getIntegrationId(), label);
330 buttons = getComponentIdList(device.getComponents(), ComponentType.BUTTON);
331 // remove button IDs >= 300 which the handler does not recognize
332 List<Integer> buttonsCopy = new ArrayList<>(buttons);
333 for (Integer c : buttonsCopy) {
335 buttons.remove(Integer.valueOf(c));
338 kpConfig = new KeypadConfigGrafikEye();
339 kpModel = kpConfig.determineModelFromComponentIds(buttons);
340 if (kpModel == null) {
341 logger.info("Unable to determine model of GrafikEye Keypad {} with button IDs: {}",
342 device.getIntegrationId(), buttons);
343 notifyDiscovery(THING_TYPE_GRAFIKEYEKEYPAD, device.getIntegrationId(), label);
345 logger.debug("Found GrafikEye keypad {} model: {}", device.getIntegrationId(), kpModel);
346 notifyDiscovery(THING_TYPE_GRAFIKEYEKEYPAD, device.getIntegrationId(), label, "model", kpModel);
351 logger.warn("Unrecognized device type {}", device.getType());
355 private void discoverKeypad(Device device, String label, ThingTypeUID ttUid, String description,
356 KeypadConfig kpConfig) {
357 List<Integer> buttons = getComponentIdList(device.getComponents(), ComponentType.BUTTON);
358 String kpModel = kpConfig.determineModelFromComponentIds(buttons);
359 if (kpModel == null) {
360 logger.info("Unable to determine model of {} {} with button IDs: {}", description,
361 device.getIntegrationId(), buttons);
362 notifyDiscovery(ttUid, device.getIntegrationId(), label);
364 logger.debug("Found {} {} model: {}", description, device.getIntegrationId(), kpModel);
365 notifyDiscovery(ttUid, device.getIntegrationId(), label, "model", kpModel);
369 private List<Integer> getComponentIdList(List<Component> clist, ComponentType ctype) {
370 List<Integer> returnList = new LinkedList<>();
371 for (Component c : clist) {
372 if (c.getComponentType() == ctype) {
373 returnList.add(c.getComponentNumber());
379 private void processOutput(Output output, Stack<String> context) {
380 OutputType type = output.getOutputType();
383 String label = generateLabel(context, output.getName());
390 case ECO_SYSTEM_FLUORESCENT:
394 notifyDiscovery(THING_TYPE_DIMMER, output.getIntegrationId(), label);
397 case CEILING_FAN_TYPE:
398 notifyDiscovery(THING_TYPE_FAN, output.getIntegrationId(), label);
405 notifyDiscovery(THING_TYPE_SWITCH, output.getIntegrationId(), label);
409 notifyDiscovery(THING_TYPE_CCO, output.getIntegrationId(), label, CCO_TYPE, CCO_TYPE_PULSED);
413 notifyDiscovery(THING_TYPE_CCO, output.getIntegrationId(), label, CCO_TYPE, CCO_TYPE_MAINTAINED);
418 notifyDiscovery(THING_TYPE_SHADE, output.getIntegrationId(), label);
422 notifyDiscovery(THING_TYPE_BLIND, output.getIntegrationId(), label, BLIND_TYPE_PARAMETER,
427 notifyDiscovery(THING_TYPE_BLIND, output.getIntegrationId(), label, BLIND_TYPE_PARAMETER,
428 BLIND_TYPE_VENETIAN);
432 logger.warn("Unrecognized output type {}", output.getType());
436 private void processTimeclocks(Timeclock timeclock, Stack<String> context) {
437 String label = generateLabel(context, timeclock.getName());
438 notifyDiscovery(THING_TYPE_TIMECLOCK, timeclock.getIntegrationId(), label);
441 private void processGreenModes(GreenMode greenmode, Stack<String> context) {
442 String label = generateLabel(context, greenmode.getName());
443 notifyDiscovery(THING_TYPE_GREENMODE, greenmode.getIntegrationId(), label);
446 private void notifyDiscovery(ThingTypeUID thingTypeUID, @Nullable Integer integrationId, String label,
447 @Nullable String propName, @Nullable Object propValue) {
448 if (integrationId == null) {
449 logger.info("Discovered {} with no integration ID", label);
454 ThingUID bridgeUID = this.bridgeHandler.getThing().getUID();
455 ThingUID uid = new ThingUID(thingTypeUID, bridgeUID, integrationId.toString());
457 Map<String, Object> properties = new HashMap<>();
459 properties.put(INTEGRATION_ID, integrationId);
461 if (propName != null && propValue != null) {
462 properties.put(propName, propValue);
465 DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID).withLabel(label)
466 .withProperties(properties).withRepresentationProperty(INTEGRATION_ID).build();
468 thingDiscovered(result);
470 logger.debug("Discovered {}", uid);
473 private void notifyDiscovery(ThingTypeUID thingTypeUID, Integer integrationId, String label) {
474 notifyDiscovery(thingTypeUID, integrationId, label, null, null);
477 private String generateLabel(Stack<String> context, String deviceName) {
478 return String.join(" ", context) + " " + deviceName;