]> git.basschouten.com Git - openhab-addons.git/blob
58534bc809f23938d3853e10d4ce90b7097d4fe9
[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.tacmi.internal.schema;
14
15 import java.math.BigDecimal;
16 import java.net.URI;
17 import java.util.ArrayList;
18 import java.util.HashSet;
19 import java.util.List;
20 import java.util.Map;
21 import java.util.Map.Entry;
22 import java.util.Set;
23 import java.util.concurrent.ExecutionException;
24 import java.util.concurrent.TimeoutException;
25
26 import org.attoparser.ParseException;
27 import org.attoparser.simple.AbstractSimpleMarkupHandler;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.util.StringUtil;
31 import org.openhab.binding.tacmi.internal.TACmiBindingConstants;
32 import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
33 import org.openhab.binding.tacmi.internal.schema.ApiPageEntry.Type;
34 import org.openhab.core.library.types.DecimalType;
35 import org.openhab.core.library.types.OnOffType;
36 import org.openhab.core.library.types.QuantityType;
37 import org.openhab.core.library.types.StringType;
38 import org.openhab.core.library.unit.SIUnits;
39 import org.openhab.core.library.unit.Units;
40 import org.openhab.core.thing.Channel;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.binding.builder.ChannelBuilder;
43 import org.openhab.core.thing.type.ChannelType;
44 import org.openhab.core.thing.type.ChannelTypeBuilder;
45 import org.openhab.core.thing.type.ChannelTypeUID;
46 import org.openhab.core.types.State;
47 import org.openhab.core.types.StateDescriptionFragmentBuilder;
48 import org.openhab.core.types.StateOption;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 /**
53  * The {@link ApiPageParser} class parses the 'API' schema page from the CMI and
54  * maps it to our channels
55  *
56  * @author Christian Niessner - Initial contribution
57  */
58 @NonNullByDefault
59 public class ApiPageParser extends AbstractSimpleMarkupHandler {
60
61     private final Logger logger = LoggerFactory.getLogger(ApiPageParser.class);
62
63     static enum ParserState {
64         INIT,
65         DATA_ENTRY
66     }
67
68     static enum FieldType {
69         UNKNOWN,
70         READ_ONLY,
71         FORM_VALUE,
72         BUTTON,
73         IGNORE
74     }
75
76     static enum ButtonValue {
77         UNKNOWN,
78         ON,
79         OFF
80     }
81
82     private ParserState parserState = ParserState.INIT;
83     private TACmiSchemaHandler taCmiSchemaHandler;
84     private TACmiChannelTypeProvider channelTypeProvider;
85     private boolean configChanged = false;
86     private FieldType fieldType = FieldType.UNKNOWN;
87     private @Nullable String id;
88     private @Nullable String address;
89     private @Nullable StringBuilder value;
90     private ButtonValue buttonValue = ButtonValue.UNKNOWN;
91     private Map<String, ApiPageEntry> entries;
92     private Set<String> seenNames = new HashSet<>();
93     private List<Channel> channels = new ArrayList<>();
94
95     public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map<String, ApiPageEntry> entries,
96             TACmiChannelTypeProvider channelTypeProvider) {
97         super();
98         this.taCmiSchemaHandler = taCmiSchemaHandler;
99         this.entries = entries;
100         this.channelTypeProvider = channelTypeProvider;
101     }
102
103     @Override
104     public void handleDocumentStart(final long startTimeNanos, final int line, final int col) throws ParseException {
105         this.parserState = ParserState.INIT;
106         this.seenNames.clear();
107         this.channels.clear();
108     }
109
110     @Override
111     public void handleDocumentEnd(final long endTimeNanos, final long totalTimeNanos, final int line, final int col)
112             throws ParseException {
113         if (this.parserState != ParserState.INIT) {
114             logger.debug("Parserstate == Init expected, but is {}", this.parserState);
115         }
116     }
117
118     @Override
119     @NonNullByDefault({})
120     public void handleStandaloneElement(final @Nullable String elementName,
121             final @Nullable Map<String, String> attributes, final boolean minimized, final int line, final int col)
122             throws ParseException {
123
124         logger.debug("Unexpected StandaloneElement in {}:{}: {} [{}]", line, col, elementName, attributes);
125     }
126
127     @Override
128     @NonNullByDefault({})
129     public void handleOpenElement(final @Nullable String elementName, final @Nullable Map<String, String> attributes,
130             final int line, final int col) throws ParseException {
131
132         if (this.parserState == ParserState.INIT && "div".equals(elementName)) {
133             this.parserState = ParserState.DATA_ENTRY;
134             String classFlags;
135             if (attributes == null) {
136                 classFlags = null;
137                 this.id = null;
138                 this.address = null;
139             } else {
140                 this.id = attributes.get("id");
141                 this.address = attributes.get("adresse");
142                 classFlags = attributes.get("class");
143             }
144             this.fieldType = FieldType.READ_ONLY;
145             this.value = new StringBuilder();
146             this.buttonValue = ButtonValue.UNKNOWN;
147             if (classFlags != null && StringUtil.isNotBlank(classFlags)) {
148                 String[] classFlagList = classFlags.split("[ \n\r]");
149                 for (String classFlag : classFlagList) {
150                     if ("changex2".equals(classFlag)) {
151                         this.fieldType = FieldType.FORM_VALUE;
152                     } else if ("buttonx2".equals(classFlag) || "taster".equals(classFlag)) {
153                         this.fieldType = FieldType.BUTTON;
154                     } else if ("visible0".equals(classFlag)) {
155                         this.buttonValue = ButtonValue.OFF;
156                     } else if ("visible1".equals(classFlag)) {
157                         this.buttonValue = ButtonValue.ON;
158                     } else if ("durchsichtig".equals(classFlag)) { // link
159                         this.fieldType = FieldType.IGNORE;
160                     } else if ("bord".equals(classFlag)) { // special button style - not of our interest...
161                     } else {
162                         logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag);
163                     }
164                 }
165             }
166         } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
167                 && "span".equals(elementName)) {
168             // ignored...
169         } else {
170             logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes);
171         }
172     }
173
174     @Override
175     public void handleCloseElement(final @Nullable String elementName, final int line, final int col)
176             throws ParseException {
177         if (this.parserState == ParserState.DATA_ENTRY && "div".equals(elementName)) {
178             this.parserState = ParserState.INIT;
179             StringBuilder sb = this.value;
180             this.value = null;
181             if (sb != null) {
182                 while (sb.length() > 0 && sb.charAt(0) == ' ') {
183                     sb = sb.delete(0, 0);
184                 }
185                 if (this.fieldType == FieldType.READ_ONLY || this.fieldType == FieldType.FORM_VALUE) {
186                     int lids = sb.lastIndexOf(":");
187                     int fsp = sb.indexOf(" ");
188                     if (fsp < 0 || lids < 0 || fsp > lids) {
189                         logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
190                                 sb);
191                     } else {
192                         String shortName = sb.substring(0, fsp).trim();
193                         String description = sb.substring(fsp + 1, lids).trim();
194                         String value = sb.substring(lids + 1).trim();
195                         getApiPageEntry(id, line, col, shortName, description, value);
196                     }
197                 } else if (this.fieldType == FieldType.BUTTON) {
198                     String sbt = sb.toString().trim().replaceAll("[\r\n ]+", " ");
199                     int fsp = sbt.indexOf(" ");
200
201                     if (fsp < 0) {
202                         logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
203                                 sbt);
204                     } else {
205                         String shortName = sbt.substring(0, fsp).trim();
206                         String description = sbt.substring(fsp + 1).trim();
207                         getApiPageEntry(id, line, col, shortName, description, this.buttonValue);
208                     }
209                 } else if (this.fieldType == FieldType.IGNORE) {
210                     // ignore
211                 } else {
212                     logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb);
213                 }
214             }
215         } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
216                 && "span".equals(elementName)) {
217             // ignored...
218         } else {
219             logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName);
220         }
221     }
222
223     @Override
224     public void handleAutoCloseElement(final @Nullable String elementName, final int line, final int col)
225             throws ParseException {
226         logger.debug("Unexpected AutoCloseElement in {}:{}: {}", line, col, elementName);
227     }
228
229     @Override
230     public void handleUnmatchedCloseElement(final @Nullable String elementName, final int line, final int col)
231             throws ParseException {
232         logger.debug("Unexpected UnmatchedCloseElement in {}:{}: {}", line, col, elementName);
233     }
234
235     @Override
236     public void handleDocType(final @Nullable String elementName, final @Nullable String publicId,
237             final @Nullable String systemId, final @Nullable String internalSubset, final int line, final int col)
238             throws ParseException {
239         logger.debug("Unexpected DocType in {}:{}: {}/{}/{}/{}", line, col, elementName, publicId, systemId,
240                 internalSubset);
241     }
242
243     @Override
244     public void handleComment(final char @Nullable [] buffer, final int offset, final int len, final int line,
245             final int col) throws ParseException {
246         logger.debug("Unexpected comment in {}:{}: {}", line, col,
247                 buffer == null ? "<null>" : new String(buffer, offset, len));
248     }
249
250     @Override
251     public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line,
252             final int col) throws ParseException {
253         logger.debug("Unexpected CDATA in {}:{}: {}", line, col,
254                 buffer == null ? "<null>" : new String(buffer, offset, len));
255     }
256
257     @Override
258     public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line,
259             final int col) throws ParseException {
260
261         if (buffer == null) {
262             return;
263         }
264
265         if (this.parserState == ParserState.DATA_ENTRY) {
266             // we append it to our current value
267             StringBuilder sb = this.value;
268             if (sb != null) {
269                 sb.append(buffer, offset, len);
270             }
271         } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n')
272                 || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) {
273             // single newline - ignore/drop it...
274         } else {
275             String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r");
276             logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg);
277         }
278     }
279
280     @Override
281     public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding,
282             final @Nullable String standalone, final int line, final int col) throws ParseException {
283         logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone);
284     }
285
286     @Override
287     public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content,
288             final int line, final int col) throws ParseException {
289         logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content);
290     }
291
292     private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description,
293             Object value) {
294         if (logger.isDebugEnabled()) {
295             logger.debug("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName,
296                     description, value);
297         }
298         if (!this.seenNames.add(shortName)) {
299             logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col,
300                     this.fieldType, shortName, description, value);
301             return;
302         }
303
304         if (value instanceof String && ((String) value).contains("can_busy")) {
305             return; // special state to indicate value currently cannot be retrieved..
306         }
307         ApiPageEntry.Type type;
308         State state;
309         String channelType;
310         ChannelTypeUID ctuid;
311         switch (this.fieldType) {
312             case BUTTON:
313                 type = Type.SWITCH_BUTTON;
314                 state = this.buttonValue == ButtonValue.ON ? OnOffType.ON : OnOffType.OFF;
315                 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
316                 channelType = "Switch";
317                 break;
318             case READ_ONLY:
319             case FORM_VALUE:
320                 String vs = (String) value;
321                 boolean isOn = "ON".equals(vs) || "EIN".equals(vs); // C.M.I. mixes up languages...
322                 if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) {
323                     channelType = "Switch";
324                     state = isOn ? OnOffType.ON : OnOffType.OFF;
325                     if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
326                         ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID;
327                         type = Type.READ_ONLY_SWITCH;
328                     } else {
329                         ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
330                         type = Type.SWITCH_FORM;
331                     }
332                 } else {
333                     try {
334                         // check if we have a numeric value (either with or without unit)
335                         String[] valParts = vs.split(" ");
336                         // It seems for some wired cases the C.M.I. uses different decimal separators for
337                         // different device types. It seems all 'new' X2-Devices use a dot as separator,
338                         // for the older pre-X2 devices (i.e. the UVR 1611) we get a comma. So we
339                         // we replace all ',' with '.' to check if it's a valid number...
340                         String val = valParts[0].replace(',', '.');
341                         BigDecimal bd = new BigDecimal(val);
342                         if (valParts.length == 2) {
343                             if ("°C".equals(valParts[1])) {
344                                 channelType = "Number:Temperature";
345                                 state = new QuantityType<>(bd, SIUnits.CELSIUS);
346                             } else if ("%".equals(valParts[1])) {
347                                 // channelType = "Number:Percent"; Number:Percent is currently not handled...
348                                 channelType = "Number:Dimensionless";
349                                 state = new QuantityType<>(bd, Units.PERCENT);
350                             } else if ("Imp".equals(valParts[1])) {
351                                 // impulses - no idea how to map this to something useful here?
352                                 channelType = "Number";
353                                 state = new DecimalType(bd);
354                             } else if ("V".equals(valParts[1])) {
355                                 channelType = "Number:Voltage";
356                                 state = new QuantityType<>(bd, Units.VOLT);
357                             } else if ("A".equals(valParts[1])) {
358                                 channelType = "Number:Current";
359                                 state = new QuantityType<>(bd, Units.AMPERE);
360                             } else if ("Hz".equals(valParts[1])) {
361                                 channelType = "Number:Frequency";
362                                 state = new QuantityType<>(bd, Units.HERTZ);
363                             } else if ("kW".equals(valParts[1])) {
364                                 channelType = "Number:Power";
365                                 bd = bd.multiply(new BigDecimal(1000));
366                                 state = new QuantityType<>(bd, Units.WATT);
367                             } else if ("kWh".equals(valParts[1])) {
368                                 channelType = "Number:Power";
369                                 bd = bd.multiply(new BigDecimal(1000));
370                                 state = new QuantityType<>(bd, Units.KILOWATT_HOUR);
371                             } else if ("l/h".equals(valParts[1])) {
372                                 channelType = "Number:Volume";
373                                 bd = bd.divide(new BigDecimal(60));
374                                 state = new QuantityType<>(bd, Units.LITRE_PER_MINUTE);
375                             } else {
376                                 channelType = "Number";
377                                 state = new DecimalType(bd);
378                                 logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
379                                         channelType, description, valParts[1]);
380                             }
381                         } else {
382                             channelType = "Number";
383                             state = new DecimalType(bd);
384                         }
385                         if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
386                             ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
387                             type = Type.READ_ONLY_NUMERIC;
388                         } else {
389                             ctuid = null;
390                             type = Type.NUMERIC_FORM;
391                         }
392                     } catch (NumberFormatException nfe) {
393                         // not a number...
394                         channelType = "String";
395                         if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
396                             ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
397                             type = Type.READ_ONLY_STATE;
398                         } else {
399                             ctuid = null;
400                             type = Type.STATE_FORM;
401                         }
402                         state = new StringType(vs);
403                     }
404                 }
405                 break;
406             case UNKNOWN:
407             case IGNORE:
408                 return;
409             default:
410                 // should't happen but we have to add default for the compiler...
411                 return;
412         }
413         ApiPageEntry e = this.entries.get(shortName);
414         if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
415             @Nullable
416             Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
417             @Nullable
418             ChangerX2Entry cx2e = null;
419             if (this.fieldType == FieldType.FORM_VALUE) {
420                 try {
421                     URI uri = this.taCmiSchemaHandler.buildUri("INCLUDE/changerx2.cgi?sadrx2=" + address);
422                     final ChangerX2Parser pp = this.taCmiSchemaHandler.parsePage(uri, new ChangerX2Parser(shortName));
423                     cx2e = pp.getParsedEntry();
424                 } catch (final ParseException | RuntimeException ex) {
425                     logger.warn("Error parsing API Scheme: {} ", ex.getMessage(), ex);
426                 } catch (final TimeoutException | InterruptedException | ExecutionException ex) {
427                     logger.warn("Error loading API Scheme: {} ", ex.getMessage());
428                 }
429             }
430             if (channel == null) {
431                 logger.debug("Creating / updating channel {} of type {} for '{}'", shortName, channelType, description);
432                 this.configChanged = true;
433                 ChannelUID channelUID = new ChannelUID(this.taCmiSchemaHandler.getThing().getUID(), shortName);
434                 ChannelBuilder channelBuilder = ChannelBuilder.create(channelUID, channelType);
435                 channelBuilder.withLabel(description);
436                 if (ctuid != null) {
437                     channelBuilder.withType(ctuid);
438                 } else if (cx2e != null) {
439                     StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create()
440                             .withReadOnly(type.readOnly);
441                     String itemType;
442                     switch (cx2e.optionType) {
443                         case NUMBER:
444                             itemType = "Number";
445                             String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN);
446                             if (min != null && !min.trim().isEmpty()) {
447                                 sdb.withMinimum(new BigDecimal(min));
448                             }
449                             String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX);
450                             if (max != null && !max.trim().isEmpty()) {
451                                 sdb.withMaximum(new BigDecimal(max));
452                             }
453                             String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP);
454                             if (step != null && !step.trim().isEmpty()) {
455                                 sdb.withStep(new BigDecimal(step));
456                             }
457                             break;
458                         case SELECT:
459                             itemType = "String";
460                             for (Entry<String, @Nullable String> entry : cx2e.options.entrySet()) {
461                                 String val = entry.getValue();
462                                 if (val != null) {
463                                     sdb.withOption(new StateOption(val, entry.getKey()));
464                                 }
465                             }
466                             break;
467                         default:
468                             throw new IllegalStateException();
469                     }
470                     ChannelType ct = ChannelTypeBuilder
471                             .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType)
472                             .withDescription("Auto-created for " + shortName).withStateDescriptionFragment(sdb.build())
473                             .build();
474                     channelTypeProvider.addChannelType(ct);
475                     channelBuilder.withType(ct.getUID());
476                 } else {
477                     logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName);
478                 }
479                 channel = channelBuilder.build(); // add configuration property...
480             }
481             this.configChanged = true;
482             e = new ApiPageEntry(type, channel, address, cx2e, state);
483             this.entries.put(shortName, e);
484         }
485         this.channels.add(e.channel);
486         e.setLastState(state);
487         this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
488     }
489
490     protected boolean isConfigChanged() {
491         return this.configChanged;
492     }
493
494     protected List<Channel> getChannels() {
495         return channels;
496     }
497 }