2 * Copyright (c) 2010-2023 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.net.URISyntaxException;
18 import java.util.ArrayList;
19 import java.util.HashSet;
20 import java.util.List;
22 import java.util.Map.Entry;
23 import java.util.Objects;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeoutException;
28 import org.attoparser.ParseException;
29 import org.attoparser.simple.AbstractSimpleMarkupHandler;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.util.StringUtil;
33 import org.openhab.binding.tacmi.internal.TACmiBindingConstants;
34 import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
35 import org.openhab.binding.tacmi.internal.schema.ApiPageEntry.Type;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.OnOffType;
38 import org.openhab.core.library.types.QuantityType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.library.unit.SIUnits;
41 import org.openhab.core.library.unit.Units;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.binding.builder.ChannelBuilder;
45 import org.openhab.core.thing.type.ChannelType;
46 import org.openhab.core.thing.type.ChannelTypeBuilder;
47 import org.openhab.core.thing.type.ChannelTypeUID;
48 import org.openhab.core.types.State;
49 import org.openhab.core.types.StateDescriptionFragmentBuilder;
50 import org.openhab.core.types.StateOption;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
55 * The {@link ApiPageParser} class parses the 'API' schema page from the CMI and
56 * maps it to our channels
58 * @author Christian Niessner - Initial contribution
61 public class ApiPageParser extends AbstractSimpleMarkupHandler {
63 private final Logger logger = LoggerFactory.getLogger(ApiPageParser.class);
65 static enum ParserState {
70 static enum FieldType {
78 static enum ButtonValue {
84 private ParserState parserState = ParserState.INIT;
85 private TACmiSchemaHandler taCmiSchemaHandler;
86 private TACmiChannelTypeProvider channelTypeProvider;
87 private boolean configChanged = false;
88 private FieldType fieldType = FieldType.UNKNOWN;
89 private @Nullable String id;
90 private @Nullable String address;
91 private @Nullable StringBuilder value;
92 private ButtonValue buttonValue = ButtonValue.UNKNOWN;
93 private Map<String, ApiPageEntry> entries;
94 private Set<String> seenNames = new HashSet<>();
95 private List<Channel> channels = new ArrayList<>();
96 // Time stamp when status request was started.
97 private final long statusRequestStartTS;
98 private static @Nullable URI configDescriptionUriAPISchemaDefaults;
100 public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map<String, ApiPageEntry> entries,
101 TACmiChannelTypeProvider channelTypeProvider) {
103 this.taCmiSchemaHandler = taCmiSchemaHandler;
104 this.entries = entries;
105 this.channelTypeProvider = channelTypeProvider;
106 this.statusRequestStartTS = System.currentTimeMillis();
107 if (configDescriptionUriAPISchemaDefaults == null) {
109 configDescriptionUriAPISchemaDefaults = new URI(
110 TACmiBindingConstants.CONFIG_DESCRIPTION_API_SCHEMA_DEFAULTS);
111 } catch (URISyntaxException ex) {
112 logger.warn("Can't create ConfigDescription URI '{}', ConfigDescription for channels not avilable!",
113 TACmiBindingConstants.CONFIG_DESCRIPTION_API_SCHEMA_DEFAULTS);
119 public void handleDocumentStart(final long startTimeNanos, final int line, final int col) throws ParseException {
120 this.parserState = ParserState.INIT;
121 this.seenNames.clear();
122 this.channels.clear();
126 public void handleDocumentEnd(final long endTimeNanos, final long totalTimeNanos, final int line, final int col)
127 throws ParseException {
128 if (this.parserState != ParserState.INIT) {
129 logger.debug("Parserstate == Init expected, but is {}", this.parserState);
134 @NonNullByDefault({})
135 public void handleStandaloneElement(final @Nullable String elementName,
136 final @Nullable Map<String, String> attributes, final boolean minimized, final int line, final int col)
137 throws ParseException {
139 logger.debug("Unexpected StandaloneElement in {}:{}: {} [{}]", line, col, elementName, attributes);
143 @NonNullByDefault({})
144 public void handleOpenElement(final @Nullable String elementName, final @Nullable Map<String, String> attributes,
145 final int line, final int col) throws ParseException {
147 if (this.parserState == ParserState.INIT && "div".equals(elementName)) {
148 this.parserState = ParserState.DATA_ENTRY;
150 if (attributes == null) {
155 this.id = attributes.get("id");
156 this.address = attributes.get("adresse");
157 classFlags = attributes.get("class");
159 this.fieldType = FieldType.READ_ONLY;
160 this.value = new StringBuilder();
161 this.buttonValue = ButtonValue.UNKNOWN;
162 if (classFlags != null && StringUtil.isNotBlank(classFlags)) {
163 String[] classFlagList = classFlags.split("[ \n\r]");
164 for (String classFlag : classFlagList) {
165 if ("changex2".equals(classFlag)) {
166 this.fieldType = FieldType.FORM_VALUE;
167 } else if ("buttonx2".equals(classFlag) || "taster".equals(classFlag)) {
168 this.fieldType = FieldType.BUTTON;
169 } else if ("visible0".equals(classFlag)) {
170 this.buttonValue = ButtonValue.OFF;
171 } else if ("visible1".equals(classFlag)) {
172 this.buttonValue = ButtonValue.ON;
173 } else if ("durchsichtig".equals(classFlag)) { // link
174 this.fieldType = FieldType.IGNORE;
175 } else if ("bord".equals(classFlag)) { // special button style - not of our interest...
177 logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag);
181 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
182 && "span".equals(elementName)) {
185 logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes);
190 public void handleCloseElement(final @Nullable String elementName, final int line, final int col)
191 throws ParseException {
192 if (this.parserState == ParserState.DATA_ENTRY && "div".equals(elementName)) {
193 this.parserState = ParserState.INIT;
194 StringBuilder sb = this.value;
197 while (sb.length() > 0 && sb.charAt(0) == ' ') {
198 sb = sb.delete(0, 0);
200 if (this.fieldType == FieldType.READ_ONLY || this.fieldType == FieldType.FORM_VALUE) {
201 int lids = sb.lastIndexOf(":");
202 int fsp = sb.indexOf(" ");
203 if (fsp < 0 || lids < 0 || fsp > lids) {
204 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
207 String shortName = sb.substring(0, fsp).trim();
208 String description = sb.substring(fsp + 1, lids).trim();
209 String value = sb.substring(lids + 1).trim();
210 getApiPageEntry(id, line, col, shortName, description, value);
212 } else if (this.fieldType == FieldType.BUTTON) {
213 String sbt = sb.toString().trim().replaceAll("[\r\n ]+", " ");
214 int fsp = sbt.indexOf(" ");
217 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
220 String shortName = sbt.substring(0, fsp).trim();
221 String description = sbt.substring(fsp + 1).trim();
222 getApiPageEntry(id, line, col, shortName, description, this.buttonValue);
224 } else if (this.fieldType == FieldType.IGNORE) {
227 logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb);
230 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
231 && "span".equals(elementName)) {
234 logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName);
239 public void handleAutoCloseElement(final @Nullable String elementName, final int line, final int col)
240 throws ParseException {
241 logger.debug("Unexpected AutoCloseElement in {}:{}: {}", line, col, elementName);
245 public void handleUnmatchedCloseElement(final @Nullable String elementName, final int line, final int col)
246 throws ParseException {
247 logger.debug("Unexpected UnmatchedCloseElement in {}:{}: {}", line, col, elementName);
251 public void handleDocType(final @Nullable String elementName, final @Nullable String publicId,
252 final @Nullable String systemId, final @Nullable String internalSubset, final int line, final int col)
253 throws ParseException {
254 logger.debug("Unexpected DocType in {}:{}: {}/{}/{}/{}", line, col, elementName, publicId, systemId,
259 public void handleComment(final char @Nullable [] buffer, final int offset, final int len, final int line,
260 final int col) throws ParseException {
261 logger.debug("Unexpected comment in {}:{}: {}", line, col,
262 buffer == null ? "<null>" : new String(buffer, offset, len));
266 public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line,
267 final int col) throws ParseException {
268 logger.debug("Unexpected CDATA in {}:{}: {}", line, col,
269 buffer == null ? "<null>" : new String(buffer, offset, len));
273 public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line,
274 final int col) throws ParseException {
276 if (buffer == null) {
280 if (this.parserState == ParserState.DATA_ENTRY) {
281 // we append it to our current value
282 StringBuilder sb = this.value;
284 sb.append(buffer, offset, len);
286 } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n')
287 || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) {
288 // single newline - ignore/drop it...
290 String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r");
291 logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg);
296 public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding,
297 final @Nullable String standalone, final int line, final int col) throws ParseException {
298 logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone);
302 public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content,
303 final int line, final int col) throws ParseException {
304 logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content);
307 private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description,
309 if (logger.isTraceEnabled()) {
310 logger.trace("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName,
313 if (!this.seenNames.add(shortName)) {
314 logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col,
315 this.fieldType, shortName, description, value);
319 if (value instanceof String && ((String) value).contains("can_busy")) {
320 return; // special state to indicate value currently cannot be retrieved..
322 ApiPageEntry.Type type;
325 ChannelTypeUID ctuid;
326 switch (this.fieldType) {
328 type = Type.SWITCH_BUTTON;
329 state = this.buttonValue == ButtonValue.ON ? OnOffType.ON : OnOffType.OFF;
330 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
331 channelType = "Switch";
335 String vs = (String) value;
336 boolean isOn = "ON".equals(vs) || "EIN".equals(vs); // C.M.I. mixes up languages...
337 if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) {
338 channelType = "Switch";
339 state = isOn ? OnOffType.ON : OnOffType.OFF;
340 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
341 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID;
342 type = Type.READ_ONLY_SWITCH;
344 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
345 type = Type.SWITCH_FORM;
349 // check if we have a numeric value (either with or without unit)
350 String[] valParts = vs.split(" ");
351 // It seems for some wired cases the C.M.I. uses different decimal separators for
352 // different device types. It seems all 'new' X2-Devices use a dot as separator,
353 // for the older pre-X2 devices (i.e. the UVR 1611) we get a comma. So we
354 // we replace all ',' with '.' to check if it's a valid number...
355 String val = valParts[0].replace(',', '.');
356 BigDecimal bd = new BigDecimal(val);
357 if (valParts.length == 2) {
358 if ("°C".equals(valParts[1])) {
359 channelType = "Number:Temperature";
360 state = new QuantityType<>(bd, SIUnits.CELSIUS);
361 } else if ("%".equals(valParts[1])) {
362 // channelType = "Number:Percent"; Number:Percent is currently not handled...
363 channelType = "Number:Dimensionless";
364 state = new QuantityType<>(bd, Units.PERCENT);
365 } else if ("Imp".equals(valParts[1])) {
366 // impulses - no idea how to map this to something useful here?
367 channelType = "Number";
368 state = new DecimalType(bd);
369 } else if ("V".equals(valParts[1])) {
370 channelType = "Number:Voltage";
371 state = new QuantityType<>(bd, Units.VOLT);
372 } else if ("A".equals(valParts[1])) {
373 channelType = "Number:Current";
374 state = new QuantityType<>(bd, Units.AMPERE);
375 } else if ("Hz".equals(valParts[1])) {
376 channelType = "Number:Frequency";
377 state = new QuantityType<>(bd, Units.HERTZ);
378 } else if ("kW".equals(valParts[1])) {
379 channelType = "Number:Power";
380 bd = bd.multiply(new BigDecimal(1000));
381 state = new QuantityType<>(bd, Units.WATT);
382 } else if ("kWh".equals(valParts[1])) {
383 channelType = "Number:Power";
384 bd = bd.multiply(new BigDecimal(1000));
385 state = new QuantityType<>(bd, Units.KILOWATT_HOUR);
386 } else if ("l/h".equals(valParts[1])) {
387 channelType = "Number:Volume";
388 bd = bd.divide(new BigDecimal(60));
389 state = new QuantityType<>(bd, Units.LITRE_PER_MINUTE);
391 channelType = "Number";
392 state = new DecimalType(bd);
393 logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
394 channelType, description, valParts[1]);
397 channelType = "Number";
398 state = new DecimalType(bd);
400 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
401 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
402 type = Type.READ_ONLY_NUMERIC;
405 type = Type.NUMERIC_FORM;
407 } catch (NumberFormatException nfe) {
409 channelType = "String";
410 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
411 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
412 type = Type.READ_ONLY_STATE;
415 type = Type.STATE_FORM;
417 state = new StringType(vs);
425 // should't happen but we have to add default for the compiler...
428 ApiPageEntry e = this.entries.get(shortName);
430 if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
432 Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
434 ChangerX2Entry cx2e = null;
435 if (this.fieldType == FieldType.FORM_VALUE) {
437 URI uri = this.taCmiSchemaHandler.buildUri("INCLUDE/changerx2.cgi?sadrx2=" + address);
438 final ChangerX2Parser pp = this.taCmiSchemaHandler.parsePage(uri, new ChangerX2Parser(shortName));
439 cx2e = pp.getParsedEntry();
440 } catch (final ParseException | RuntimeException ex) {
441 logger.warn("Error parsing API Scheme: {} ", ex.getMessage(), ex);
442 } catch (final TimeoutException | InterruptedException | ExecutionException ex) {
443 logger.warn("Error loading API Scheme: {} ", ex.getMessage());
446 if (channel == null || !Objects.equals(ctuid, channel.getChannelTypeUID())) {
447 logger.debug("Creating / updating channel {} of type {} for '{}'", shortName, channelType, description);
448 this.configChanged = true;
449 ChannelUID channelUID = new ChannelUID(this.taCmiSchemaHandler.getThing().getUID(), shortName);
450 ChannelBuilder channelBuilder = ChannelBuilder.create(channelUID, channelType);
451 channelBuilder.withLabel(description);
453 channelBuilder.withType(ctuid);
454 } else if (cx2e != null) {
455 ChannelType ct = buildAndRegisterChannelType(shortName, type, cx2e);
457 channelBuilder.withType(ct.getUID());
459 logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName);
461 channel = channelBuilder.build(); // add configuration property...
462 } else if (ctuid == null && cx2e != null) {
463 // custom channel type - check if it already exists and recreate when needed...
464 ChannelTypeUID curCtuid = channel.getChannelTypeUID();
465 if (curCtuid != null) {
466 ChannelType ct = channelTypeProvider.getChannelType(curCtuid, null);
468 buildAndRegisterChannelType(shortName, type, cx2e);
472 this.configChanged = true;
473 e = new ApiPageEntry(type, channel, address, cx2e, state);
474 this.entries.put(shortName, e);
479 this.channels.add(e.channel);
480 // only update the state when there was no state change sent to C.M.I. after we started
481 // polling the state. It might deliver the previous / old state.
482 if (e.getLastCommandTS() < this.statusRequestStartTS) {
483 Number updatePolicyI = (Number) e.channel.getConfiguration().get("updatePolicy");
484 int updatePolicy = updatePolicyI == null ? 0 : updatePolicyI.intValue();
485 switch (updatePolicy) {
488 // we do 'On-Fetch' update when channel is changeable, otherwise 'On-Change'
494 if (isNewEntry || !state.equals(e.getLastState())) {
495 e.setLastState(state);
496 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
499 case READ_ONLY_NUMERIC:
500 case READ_ONLY_STATE:
501 case READ_ONLY_SWITCH:
502 e.setLastState(state);
503 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
508 e.setLastState(state);
509 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
512 if (isNewEntry || !state.equals(e.getLastState())) {
513 e.setLastState(state);
514 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
521 private ChannelType buildAndRegisterChannelType(String shortName, Type type, ChangerX2Entry cx2e) {
522 StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create().withReadOnly(type.readOnly);
524 switch (cx2e.optionType) {
527 String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN);
528 if (min != null && !min.trim().isEmpty()) {
529 sdb.withMinimum(new BigDecimal(min));
531 String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX);
532 if (max != null && !max.trim().isEmpty()) {
533 sdb.withMaximum(new BigDecimal(max));
535 String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP);
536 if (step != null && !step.trim().isEmpty()) {
537 sdb.withStep(new BigDecimal(step));
542 for (Entry<String, @Nullable String> entry : cx2e.options.entrySet()) {
543 String val = entry.getValue();
545 sdb.withOption(new StateOption(val, entry.getKey()));
550 throw new IllegalStateException();
552 ChannelTypeBuilder<?> ctb = ChannelTypeBuilder
553 .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType)
554 .withDescription("Auto-created for " + shortName).withStateDescriptionFragment(sdb.build());
556 // add config description URI
557 URI cdu = configDescriptionUriAPISchemaDefaults;
559 ctb = ctb.withConfigDescriptionURI(cdu);
562 ChannelType ct = ctb.build();
563 channelTypeProvider.addChannelType(ct);
567 protected boolean isConfigChanged() {
568 return this.configChanged;
571 protected List<Channel> getChannels() {