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.tacmi.internal.schema;
15 import java.math.BigDecimal;
17 import java.util.ArrayList;
18 import java.util.HashSet;
19 import java.util.List;
21 import java.util.Map.Entry;
23 import java.util.concurrent.ExecutionException;
24 import java.util.concurrent.TimeoutException;
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.SmartHomeUnits;
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;
53 * The {@link ApiPageParser} class parses the 'API' schema page from the CMI and
54 * maps it to our channels
56 * @author Christian Niessner - Initial contribution
59 public class ApiPageParser extends AbstractSimpleMarkupHandler {
61 private final Logger logger = LoggerFactory.getLogger(ApiPageParser.class);
63 static enum ParserState {
68 static enum FieldType {
76 static enum ButtonValue {
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, @Nullable ApiPageEntry> entries;
92 private Set<String> seenNames = new HashSet<>();
93 private List<Channel> channels = new ArrayList<>();
95 public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map<String, @Nullable ApiPageEntry> entries,
96 TACmiChannelTypeProvider channelTypeProvider) {
98 this.taCmiSchemaHandler = taCmiSchemaHandler;
99 this.entries = entries;
100 this.channelTypeProvider = channelTypeProvider;
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();
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);
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 {
124 logger.debug("Unexpected StandaloneElement in {}:{}: {} [{}]", line, col, elementName, attributes);
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 {
132 if (this.parserState == ParserState.INIT && "div".equals(elementName)) {
133 this.parserState = ParserState.DATA_ENTRY;
135 if (attributes == null) {
140 this.id = attributes.get("id");
141 this.address = attributes.get("adresse");
142 classFlags = attributes.get("class");
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...
162 logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag);
166 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
167 && "span".equals(elementName)) {
170 logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes);
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;
182 while (sb.length() > 0 && sb.charAt(0) == ' ') {
183 sb = sb.delete(0, 0);
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,
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);
197 } else if (this.fieldType == FieldType.BUTTON) {
198 String sbt = sb.toString().trim().replaceAll("[\r\n ]+", " ");
199 int fsp = sbt.indexOf(" ");
202 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
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);
209 } else if (this.fieldType == FieldType.IGNORE) {
212 logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb);
215 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
216 && "span".equals(elementName)) {
219 logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName);
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);
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);
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,
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, new String(buffer, offset, len));
250 public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line,
251 final int col) throws ParseException {
252 logger.debug("Unexpected CDATA in {}:{}: {}", line, col, new String(buffer, offset, len));
256 public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line,
257 final int col) throws ParseException {
259 if (buffer == null) {
263 if (this.parserState == ParserState.DATA_ENTRY) {
264 // we append it to our current value
265 StringBuilder sb = this.value;
267 sb.append(buffer, offset, len);
269 } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n')
270 || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) {
271 // single newline - ignore/drop it...
273 String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r");
274 logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg);
279 public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding,
280 final @Nullable String standalone, final int line, final int col) throws ParseException {
281 logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone);
285 public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content,
286 final int line, final int col) throws ParseException {
287 logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content);
290 private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description,
292 if (logger.isDebugEnabled()) {
293 logger.debug("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName,
296 if (!this.seenNames.add(shortName)) {
297 logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col,
298 this.fieldType, shortName, description, value);
302 if (value instanceof String && ((String) value).contains("can_busy")) {
303 return; // special state to indicate value currently cannot be retrieved..
305 ApiPageEntry.Type type;
308 ChannelTypeUID ctuid;
309 switch (this.fieldType) {
311 type = Type.SWITCH_BUTTON;
312 state = this.buttonValue == ButtonValue.ON ? OnOffType.ON : OnOffType.OFF;
313 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
314 channelType = "Switch";
318 String vs = (String) value;
319 boolean isOn = "ON".equals(vs) || "EIN".equals(vs); // C.M.I. mixes up languages...
320 if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) {
321 channelType = "Switch";
322 state = isOn ? OnOffType.ON : OnOffType.OFF;
323 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
324 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID;
325 type = Type.READ_ONLY_SWITCH;
327 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
328 type = Type.SWITCH_FORM;
332 // check if we have a numeric value (either with or without unit)
333 String[] valParts = vs.split(" ");
334 // It seems for some wired cases the C.M.I. uses different decimal separators for
335 // different device types. It seems all 'new' X2-Devices use a dot as separator,
336 // for the older pre-X2 devices (i.e. the UVR 1611) we get a comma. So we
337 // we replace all ',' with '.' to check if it's a valid number...
338 String val = valParts[0].replace(',', '.');
339 BigDecimal bd = new BigDecimal(val);
340 if (valParts.length == 2) {
341 if ("°C".equals(valParts[1])) {
342 channelType = "Number:Temperature";
343 state = new QuantityType<>(bd, SIUnits.CELSIUS);
344 } else if ("%".equals(valParts[1])) {
345 channelType = "Number:Percent";
346 state = new QuantityType<>(bd, SmartHomeUnits.PERCENT);
347 } else if ("Imp".equals(valParts[1])) {
348 // impulses - no idea how to map this to something useful here?
349 channelType = "Number";
350 state = new DecimalType(bd);
351 } else if ("V".equals(valParts[1])) {
352 channelType = "Number:Voltage";
353 state = new QuantityType<>(bd, SmartHomeUnits.VOLT);
354 } else if ("A".equals(valParts[1])) {
355 channelType = "Number:Current";
356 state = new QuantityType<>(bd, SmartHomeUnits.AMPERE);
357 } else if ("Hz".equals(valParts[1])) {
358 channelType = "Number:Frequency";
359 state = new QuantityType<>(bd, SmartHomeUnits.HERTZ);
360 } else if ("kW".equals(valParts[1])) {
361 channelType = "Number:Power";
362 bd = bd.multiply(new BigDecimal(1000));
363 state = new QuantityType<>(bd, SmartHomeUnits.WATT);
364 } else if ("kWh".equals(valParts[1])) {
365 channelType = "Number:Power";
366 bd = bd.multiply(new BigDecimal(1000));
367 state = new QuantityType<>(bd, SmartHomeUnits.KILOWATT_HOUR);
368 } else if ("l/h".equals(valParts[1])) {
369 channelType = "Number:Volume";
370 bd = bd.divide(new BigDecimal(60));
371 state = new QuantityType<>(bd, SmartHomeUnits.LITRE_PER_MINUTE);
373 channelType = "Number";
374 state = new DecimalType(bd);
375 logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
376 channelType, description, valParts[1]);
379 channelType = "Number";
380 state = new DecimalType(bd);
382 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
383 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
384 type = Type.READ_ONLY_NUMERIC;
387 type = Type.NUMERIC_FORM;
389 } catch (NumberFormatException nfe) {
391 channelType = "String";
392 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
393 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
394 type = Type.READ_ONLY_STATE;
397 type = Type.STATE_FORM;
399 state = new StringType(vs);
407 // should't happen but we have to add default for the compiler...
410 ApiPageEntry e = this.entries.get(shortName);
411 if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
413 Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
415 ChangerX2Entry cx2e = null;
416 if (this.fieldType == FieldType.FORM_VALUE) {
418 URI uri = this.taCmiSchemaHandler.buildUri("INCLUDE/changerx2.cgi?sadrx2=" + address);
419 final ChangerX2Parser pp = this.taCmiSchemaHandler.parsePage(uri, new ChangerX2Parser(shortName));
420 cx2e = pp.getParsedEntry();
421 } catch (final ParseException | RuntimeException ex) {
422 logger.warn("Error parsing API Scheme: {} ", ex.getMessage(), ex);
423 } catch (final TimeoutException | InterruptedException | ExecutionException ex) {
424 logger.warn("Error loading API Scheme: {} ", ex.getMessage());
427 if (channel == null) {
428 logger.debug("Creating / updating channel {} of type {} for '{}'", shortName, channelType, description);
429 this.configChanged = true;
430 ChannelUID channelUID = new ChannelUID(this.taCmiSchemaHandler.getThing().getUID(), shortName);
431 ChannelBuilder channelBuilder = ChannelBuilder.create(channelUID, channelType);
432 channelBuilder.withLabel(description);
434 channelBuilder.withType(ctuid);
435 } else if (cx2e != null) {
436 StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create()
437 .withReadOnly(type.readOnly);
439 switch (cx2e.optionType) {
442 String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN);
443 if (min != null && !min.trim().isEmpty()) {
444 sdb.withMinimum(new BigDecimal(min));
446 String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX);
447 if (max != null && !max.trim().isEmpty()) {
448 sdb.withMaximum(new BigDecimal(max));
450 String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP);
451 if (step != null && !step.trim().isEmpty()) {
452 sdb.withStep(new BigDecimal(step));
457 for (Entry<String, @Nullable String> entry : cx2e.options.entrySet()) {
458 String val = entry.getValue();
460 sdb.withOption(new StateOption(val, entry.getKey()));
465 throw new IllegalStateException();
467 ChannelType ct = ChannelTypeBuilder
468 .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType)
469 .withDescription("Auto-created for " + shortName)
470 .withStateDescription(sdb.build().toStateDescription()).build();
471 channelTypeProvider.addChannelType(ct);
472 channelBuilder.withType(ct.getUID());
474 logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName);
476 channel = channelBuilder.build(); // add configuration property...
478 this.configChanged = true;
479 e = new ApiPageEntry(type, channel, address, cx2e, state);
480 this.entries.put(shortName, e);
482 this.channels.add(e.channel);
483 e.setLastState(state);
484 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
487 protected boolean isConfigChanged() {
488 return this.configChanged;
491 protected List<Channel> getChannels() {