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.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;
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, ApiPageEntry> entries;
92 private Set<String> seenNames = new HashSet<>();
93 private List<Channel> channels = new ArrayList<>();
95 public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map<String, 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,
247 buffer == null ? "<null>" : new String(buffer, offset, len));
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));
258 public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line,
259 final int col) throws ParseException {
261 if (buffer == null) {
265 if (this.parserState == ParserState.DATA_ENTRY) {
266 // we append it to our current value
267 StringBuilder sb = this.value;
269 sb.append(buffer, offset, len);
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...
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);
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);
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);
292 private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description,
294 if (logger.isDebugEnabled()) {
295 logger.debug("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName,
298 if (!this.seenNames.add(shortName)) {
299 logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col,
300 this.fieldType, shortName, description, value);
304 if (value instanceof String && ((String) value).contains("can_busy")) {
305 return; // special state to indicate value currently cannot be retrieved..
307 ApiPageEntry.Type type;
310 ChannelTypeUID ctuid;
311 switch (this.fieldType) {
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";
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;
329 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
330 type = Type.SWITCH_FORM;
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);
376 channelType = "Number";
377 state = new DecimalType(bd);
378 logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
379 channelType, description, valParts[1]);
382 channelType = "Number";
383 state = new DecimalType(bd);
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;
390 type = Type.NUMERIC_FORM;
392 } catch (NumberFormatException nfe) {
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;
400 type = Type.STATE_FORM;
402 state = new StringType(vs);
410 // should't happen but we have to add default for the compiler...
413 ApiPageEntry e = this.entries.get(shortName);
414 if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
416 Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
418 ChangerX2Entry cx2e = null;
419 if (this.fieldType == FieldType.FORM_VALUE) {
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());
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);
437 channelBuilder.withType(ctuid);
438 } else if (cx2e != null) {
439 StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create()
440 .withReadOnly(type.readOnly);
442 switch (cx2e.optionType) {
445 String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN);
446 if (min != null && !min.trim().isEmpty()) {
447 sdb.withMinimum(new BigDecimal(min));
449 String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX);
450 if (max != null && !max.trim().isEmpty()) {
451 sdb.withMaximum(new BigDecimal(max));
453 String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP);
454 if (step != null && !step.trim().isEmpty()) {
455 sdb.withStep(new BigDecimal(step));
460 for (Entry<String, @Nullable String> entry : cx2e.options.entrySet()) {
461 String val = entry.getValue();
463 sdb.withOption(new StateOption(val, entry.getKey()));
468 throw new IllegalStateException();
470 ChannelType ct = ChannelTypeBuilder
471 .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType)
472 .withDescription("Auto-created for " + shortName)
473 .withStateDescription(sdb.build().toStateDescription()).build();
474 channelTypeProvider.addChannelType(ct);
475 channelBuilder.withType(ct.getUID());
477 logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName);
479 channel = channelBuilder.build(); // add configuration property...
481 this.configChanged = true;
482 e = new ApiPageEntry(type, channel, address, cx2e, state);
483 this.entries.put(shortName, e);
485 this.channels.add(e.channel);
486 e.setLastState(state);
487 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
490 protected boolean isConfigChanged() {
491 return this.configChanged;
494 protected List<Channel> getChannels() {