]> git.basschouten.com Git - openhab-addons.git/blob
0e00a99246c3b2f40766391c727550313187fd4f
[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.discovery;
14
15 import static org.openhab.binding.lutron.internal.LutronBindingConstants.*;
16
17 import java.io.BufferedReader;
18 import java.io.File;
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;
28 import java.util.Map;
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;
36
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;
74
75 /**
76  * The {@link LutronDeviceDiscoveryService} finds all devices paired with Lutron bridges by retrieving the
77  * configuration XML from them via HTTP.
78  *
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.
83  */
84 @NonNullByDefault
85 public class LutronDeviceDiscoveryService extends AbstractDiscoveryService {
86
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
90
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);
94
95     private final Logger logger = LoggerFactory.getLogger(LutronDeviceDiscoveryService.class);
96
97     private final IPBridgeHandler bridgeHandler;
98     private DbXmlInfoReader dbXmlInfoReader = new DbXmlInfoReader();
99
100     private final HttpClient httpClient;
101
102     private @Nullable Future<?> scanTask;
103
104     public LutronDeviceDiscoveryService(IPBridgeHandler bridgeHandler, HttpClient httpClient)
105             throws IllegalArgumentException {
106         super(LutronHandlerFactory.DISCOVERABLE_DEVICE_TYPES_UIDS, DISCOVERY_SERVICE_TIMEOUT);
107
108         this.bridgeHandler = bridgeHandler;
109         this.httpClient = httpClient;
110     }
111
112     @Override
113     protected synchronized void startScan() {
114         Future<?> scanTask = this.scanTask;
115         if (scanTask == null || scanTask.isDone()) {
116             this.scanTask = scheduler.submit(this::asyncDiscoveryTask);
117         }
118     }
119
120     private synchronized void asyncDiscoveryTask() {
121         try {
122             readDeviceDatabase();
123         } catch (RuntimeException e) {
124             logger.warn("Runtime exception scanning for devices: {}", e.getMessage(), e);
125
126             if (scanListener != null) {
127                 scanListener.onErrorOccurred(null); // null so it won't log a stack trace
128             }
129         }
130     }
131
132     private void readDeviceDatabase() {
133         Project project = null;
134
135         if (bridgeHandler.getIPBridgeConfig() == null) {
136             logger.debug("Unable to get bridge config. Exiting.");
137             return;
138         }
139         String discFileName = bridgeHandler.getIPBridgeConfig().discoveryFile;
140         String address = "http://" + bridgeHandler.getIPBridgeConfig().ipAddress + "/DbXmlInfo.xml";
141
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;
147
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);
151
152             try {
153                 response = listener.get(HTTP_REQUEST_TIMEOUT, TimeUnit.SECONDS);
154             } catch (InterruptedException | TimeoutException | ExecutionException e) {
155                 logger.info("Exception getting HTTP response: {}", e.getMessage());
156             }
157
158             if (response != null && response.getStatus() == HttpStatus.OK_200) {
159                 logger.trace("Received good http response.");
160
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);
166
167                     project = dbXmlInfoReader.readFromXML(xmlBufReader);
168                     if (project == null) {
169                         logger.info("Failed to parse XML project file from {}", address);
170                     }
171                 } catch (IOException e) {
172                     logger.info("IOException while processing XML project file: {}", e.getMessage());
173                 }
174             } else {
175                 if (response != null) {
176                     logger.info("Received HTTP error response: {} {}", response.getStatus(), response.getReason());
177                 } else {
178                     logger.info("No response for HTTP request.");
179                 }
180             }
181         } else {
182             // Read XML from file
183             File xmlFile = new File(discFileName);
184
185             try (BufferedReader xmlReader = Files.newBufferedReader(xmlFile.toPath(), StandardCharsets.UTF_8)) {
186                 flushPrePrologLines(xmlReader);
187
188                 project = dbXmlInfoReader.readFromXML(xmlReader);
189                 if (project == null) {
190                     logger.info("Could not process XML project file {}", discFileName);
191                 }
192             } catch (IOException | SecurityException e) {
193                 logger.info("Exception reading XML project file {} : {}", discFileName, e.getMessage());
194             }
195         }
196
197         if (project != null) {
198             Stack<String> locationContext = new Stack<>();
199
200             for (Area area : project.getAreas()) {
201                 processArea(area, locationContext);
202             }
203             for (Timeclock timeclock : project.getTimeclocks()) {
204                 processTimeclocks(timeclock, locationContext);
205             }
206             for (GreenMode greenMode : project.getGreenModes()) {
207                 processGreenModes(greenMode, locationContext);
208             }
209         }
210     }
211
212     /**
213      * Flushes any lines or characters before the start of the XML declaration in the supplied BufferedReader.
214      *
215      * @param xmlReader BufferedReader source of the XML document
216      * @throws IOException
217      */
218     private void flushPrePrologLines(BufferedReader xmlReader) throws IOException {
219         String inLine = null;
220         xmlReader.mark(DECLARATION_MAX_LEN);
221         boolean foundXmlDec = false;
222
223         while (!foundXmlDec && (inLine = xmlReader.readLine()) != null) {
224             Matcher matcher = XML_DECLARATION_PATTERN.matcher(inLine);
225             if (matcher.find()) {
226                 foundXmlDec = true;
227                 xmlReader.reset();
228                 if (matcher.start() > 0) {
229                     logger.trace("Discarding {} characters.", matcher.start());
230                     xmlReader.skip(matcher.start());
231                 }
232             } else {
233                 logger.trace("Discarding line: {}", inLine);
234                 xmlReader.mark(DECLARATION_MAX_LEN);
235             }
236         }
237     }
238
239     private void processArea(Area area, Stack<String> context) {
240         context.push(area.getName());
241
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);
247             }
248         }
249
250         for (Output output : area.getOutputs()) {
251             processOutput(output, context);
252         }
253
254         for (Area subarea : area.getAreas()) {
255             processArea(subarea, context);
256         }
257
258         context.pop();
259     }
260
261     private void processDeviceGroup(Area area, DeviceGroup deviceGroup, Stack<String> context) {
262         context.push(deviceGroup.getName());
263
264         for (Device device : deviceGroup.getDevices()) {
265             processDevice(area, device, context);
266         }
267
268         context.pop();
269     }
270
271     private void processDevice(Area area, Device device, Stack<String> context) {
272         List<Integer> buttons;
273         KeypadConfig kpConfig;
274         String kpModel;
275
276         DeviceType type = device.getDeviceType();
277
278         if (type != null) {
279             String label = generateLabel(context, device.getName());
280
281             switch (type) {
282                 case MOTION_SENSOR:
283                     notifyDiscovery(THING_TYPE_OCCUPANCYSENSOR, device.getIntegrationId(), label);
284                     notifyDiscovery(THING_TYPE_OGROUP, area.getIntegrationId(), area.getName());
285                     break;
286
287                 case SEETOUCH_KEYPAD:
288                 case HYBRID_SEETOUCH_KEYPAD:
289                     kpConfig = new KeypadConfigSeetouch();
290                     discoverKeypad(device, label, THING_TYPE_KEYPAD, "seeTouch Keypad", kpConfig);
291                     break;
292
293                 case INTERNATIONAL_SEETOUCH_KEYPAD:
294                     kpConfig = new KeypadConfigIntlSeetouch();
295                     discoverKeypad(device, label, THING_TYPE_INTLKEYPAD, "International seeTouch Keypad", kpConfig);
296                     break;
297
298                 case SEETOUCH_TABLETOP_KEYPAD:
299                     kpConfig = new KeypadConfigTabletopSeetouch();
300                     discoverKeypad(device, label, THING_TYPE_TTKEYPAD, "Tabletop seeTouch Keypad", kpConfig);
301                     break;
302
303                 case PALLADIOM_KEYPAD:
304                     kpConfig = new KeypadConfigPalladiom();
305                     discoverKeypad(device, label, THING_TYPE_PALLADIOMKEYPAD, "Palladiom Keypad", kpConfig);
306                     break;
307
308                 case PICO_KEYPAD:
309                     kpConfig = new KeypadConfigPico();
310                     discoverKeypad(device, label, THING_TYPE_PICO, "Pico Keypad", kpConfig);
311                     break;
312
313                 case VISOR_CONTROL_RECEIVER:
314                     notifyDiscovery(THING_TYPE_VCRX, device.getIntegrationId(), label);
315                     break;
316
317                 case WCI:
318                     notifyDiscovery(THING_TYPE_WCI, device.getIntegrationId(), label);
319                     break;
320
321                 case MAIN_REPEATER:
322                     notifyDiscovery(THING_TYPE_VIRTUALKEYPAD, device.getIntegrationId(), label);
323                     break;
324
325                 case QS_IO_INTERFACE:
326                     notifyDiscovery(THING_TYPE_QSIO, device.getIntegrationId(), label);
327                     break;
328
329                 case GRAFIK_EYE_QS:
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) {
334                         if (c >= 300) {
335                             buttons.remove(Integer.valueOf(c));
336                         }
337                     }
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);
344                     } else {
345                         logger.debug("Found GrafikEye keypad {} model: {}", device.getIntegrationId(), kpModel);
346                         notifyDiscovery(THING_TYPE_GRAFIKEYEKEYPAD, device.getIntegrationId(), label, "model", kpModel);
347                     }
348                     break;
349             }
350         } else {
351             logger.warn("Unrecognized device type {}", device.getType());
352         }
353     }
354
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);
363         } else {
364             logger.debug("Found {} {} model: {}", description, device.getIntegrationId(), kpModel);
365             notifyDiscovery(ttUid, device.getIntegrationId(), label, "model", kpModel);
366         }
367     }
368
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());
374             }
375         }
376         return returnList;
377     }
378
379     private void processOutput(Output output, Stack<String> context) {
380         OutputType type = output.getOutputType();
381
382         if (type != null) {
383             String label = generateLabel(context, output.getName());
384
385             switch (type) {
386                 case INC:
387                 case MLV:
388                 case ELV:
389                 case DALI:
390                 case ECO_SYSTEM_FLUORESCENT:
391                 case FLUORESCENT_DB:
392                 case ZERO_TO_TEN:
393                 case AUTO_DETECT:
394                     notifyDiscovery(THING_TYPE_DIMMER, output.getIntegrationId(), label);
395                     break;
396
397                 case CEILING_FAN_TYPE:
398                     notifyDiscovery(THING_TYPE_FAN, output.getIntegrationId(), label);
399                     break;
400
401                 case NON_DIM:
402                 case NON_DIM_INC:
403                 case NON_DIM_ELV:
404                 case RELAY_LIGHTING:
405                     notifyDiscovery(THING_TYPE_SWITCH, output.getIntegrationId(), label);
406                     break;
407
408                 case CCO_PULSED:
409                     notifyDiscovery(THING_TYPE_CCO, output.getIntegrationId(), label, CCO_TYPE, CCO_TYPE_PULSED);
410                     break;
411
412                 case CCO_MAINTAINED:
413                     notifyDiscovery(THING_TYPE_CCO, output.getIntegrationId(), label, CCO_TYPE, CCO_TYPE_MAINTAINED);
414                     break;
415
416                 case SYSTEM_SHADE:
417                 case MOTOR:
418                     notifyDiscovery(THING_TYPE_SHADE, output.getIntegrationId(), label);
419                     break;
420
421                 case SHEER_BLIND:
422                     notifyDiscovery(THING_TYPE_BLIND, output.getIntegrationId(), label, BLIND_TYPE_PARAMETER,
423                             BLIND_TYPE_SHEER);
424                     break;
425
426                 case VENETIAN_BLIND:
427                     notifyDiscovery(THING_TYPE_BLIND, output.getIntegrationId(), label, BLIND_TYPE_PARAMETER,
428                             BLIND_TYPE_VENETIAN);
429                     break;
430             }
431         } else {
432             logger.warn("Unrecognized output type {}", output.getType());
433         }
434     }
435
436     private void processTimeclocks(Timeclock timeclock, Stack<String> context) {
437         String label = generateLabel(context, timeclock.getName());
438         notifyDiscovery(THING_TYPE_TIMECLOCK, timeclock.getIntegrationId(), label);
439     }
440
441     private void processGreenModes(GreenMode greenmode, Stack<String> context) {
442         String label = generateLabel(context, greenmode.getName());
443         notifyDiscovery(THING_TYPE_GREENMODE, greenmode.getIntegrationId(), label);
444     }
445
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);
450
451             return;
452         }
453
454         ThingUID bridgeUID = this.bridgeHandler.getThing().getUID();
455         ThingUID uid = new ThingUID(thingTypeUID, bridgeUID, integrationId.toString());
456
457         Map<String, Object> properties = new HashMap<>();
458
459         properties.put(INTEGRATION_ID, integrationId);
460
461         if (propName != null && propValue != null) {
462             properties.put(propName, propValue);
463         }
464
465         DiscoveryResult result = DiscoveryResultBuilder.create(uid).withBridge(bridgeUID).withLabel(label)
466                 .withProperties(properties).withRepresentationProperty(INTEGRATION_ID).build();
467
468         thingDiscovered(result);
469
470         logger.debug("Discovered {}", uid);
471     }
472
473     private void notifyDiscovery(ThingTypeUID thingTypeUID, Integer integrationId, String label) {
474         notifyDiscovery(thingTypeUID, integrationId, label, null, null);
475     }
476
477     private String generateLabel(Stack<String> context, String deviceName) {
478         return String.join(" ", context) + " " + deviceName;
479     }
480 }