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