2 * Copyright (c) 2010-2024 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.time.LocalDate;
19 import java.time.LocalTime;
20 import java.time.ZoneId;
21 import java.time.format.DateTimeFormatter;
22 import java.util.ArrayList;
23 import java.util.HashSet;
24 import java.util.List;
26 import java.util.Map.Entry;
27 import java.util.Objects;
29 import java.util.concurrent.ExecutionException;
30 import java.util.concurrent.TimeoutException;
32 import org.attoparser.ParseException;
33 import org.attoparser.simple.AbstractSimpleMarkupHandler;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.eclipse.jetty.util.StringUtil;
37 import org.openhab.binding.tacmi.internal.TACmiBindingConstants;
38 import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
39 import org.openhab.binding.tacmi.internal.schema.ApiPageEntry.Type;
40 import org.openhab.core.library.types.DateTimeType;
41 import org.openhab.core.library.types.DecimalType;
42 import org.openhab.core.library.types.OnOffType;
43 import org.openhab.core.library.types.QuantityType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.library.unit.SIUnits;
46 import org.openhab.core.library.unit.Units;
47 import org.openhab.core.thing.Channel;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.binding.builder.ChannelBuilder;
50 import org.openhab.core.thing.type.ChannelType;
51 import org.openhab.core.thing.type.ChannelTypeBuilder;
52 import org.openhab.core.thing.type.ChannelTypeUID;
53 import org.openhab.core.types.State;
54 import org.openhab.core.types.StateDescriptionFragmentBuilder;
55 import org.openhab.core.types.StateOption;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
60 * The {@link ApiPageParser} class parses the 'API' schema page from the CMI and
61 * maps it to our channels
63 * @author Christian Niessner - Initial contribution
66 public class ApiPageParser extends AbstractSimpleMarkupHandler {
68 private final Logger logger = LoggerFactory.getLogger(ApiPageParser.class);
70 static enum ParserState {
75 static enum FieldType {
83 static enum ButtonValue {
89 private ParserState parserState = ParserState.INIT;
90 private TACmiSchemaHandler taCmiSchemaHandler;
91 private TACmiChannelTypeProvider channelTypeProvider;
92 private boolean configChanged = false;
93 private FieldType fieldType = FieldType.UNKNOWN;
94 private @Nullable String id;
95 private @Nullable String address;
96 private @Nullable StringBuilder value;
97 private ButtonValue buttonValue = ButtonValue.UNKNOWN;
98 private Map<String, ApiPageEntry> entries;
99 private Set<String> seenNames = new HashSet<>();
100 private List<Channel> channels = new ArrayList<>();
101 // Time stamp when status request was started.
102 private final long statusRequestStartTS;
103 private static @Nullable URI configDescriptionUriAPISchemaDefaults;
105 public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map<String, ApiPageEntry> entries,
106 TACmiChannelTypeProvider channelTypeProvider) {
108 this.taCmiSchemaHandler = taCmiSchemaHandler;
109 this.entries = entries;
110 this.channelTypeProvider = channelTypeProvider;
111 this.statusRequestStartTS = System.currentTimeMillis();
112 if (configDescriptionUriAPISchemaDefaults == null) {
114 configDescriptionUriAPISchemaDefaults = new URI(
115 TACmiBindingConstants.CONFIG_DESCRIPTION_API_SCHEMA_DEFAULTS);
116 } catch (URISyntaxException ex) {
117 logger.warn("Can't create ConfigDescription URI '{}', ConfigDescription for channels not avilable!",
118 TACmiBindingConstants.CONFIG_DESCRIPTION_API_SCHEMA_DEFAULTS);
124 public void handleDocumentStart(final long startTimeNanos, final int line, final int col) throws ParseException {
125 this.parserState = ParserState.INIT;
126 this.seenNames.clear();
127 this.channels.clear();
131 public void handleDocumentEnd(final long endTimeNanos, final long totalTimeNanos, final int line, final int col)
132 throws ParseException {
133 if (this.parserState != ParserState.INIT) {
134 logger.debug("Parserstate == Init expected, but is {}", this.parserState);
139 @NonNullByDefault({})
140 public void handleStandaloneElement(final @Nullable String elementName,
141 final @Nullable Map<String, String> attributes, final boolean minimized, final int line, final int col)
142 throws ParseException {
143 logger.debug("Unexpected StandaloneElement in {}:{}: {} [{}]", line, col, elementName, attributes);
147 @NonNullByDefault({})
148 public void handleOpenElement(final @Nullable String elementName, final @Nullable Map<String, String> attributes,
149 final int line, final int col) throws ParseException {
150 if (this.parserState == ParserState.INIT && "div".equals(elementName)) {
151 this.parserState = ParserState.DATA_ENTRY;
153 if (attributes == null) {
158 this.id = attributes.get("id");
159 this.address = attributes.get("adresse");
160 classFlags = attributes.get("class");
162 this.fieldType = FieldType.READ_ONLY;
163 this.value = new StringBuilder();
164 this.buttonValue = ButtonValue.UNKNOWN;
165 if (classFlags != null && StringUtil.isNotBlank(classFlags)) {
166 String[] classFlagList = classFlags.split("[ \n\r]");
167 for (String classFlag : classFlagList) {
168 if ("changex2".equals(classFlag)) {
169 this.fieldType = FieldType.FORM_VALUE;
170 } else if ("buttonx2".equals(classFlag) || "taster".equals(classFlag)) {
171 this.fieldType = FieldType.BUTTON;
172 } else if ("visible0".equals(classFlag)) {
173 this.buttonValue = ButtonValue.OFF;
174 } else if ("visible1".equals(classFlag)) {
175 this.buttonValue = ButtonValue.ON;
176 } else if ("durchsichtig".equals(classFlag)) { // link
177 this.fieldType = FieldType.IGNORE;
178 } else if ("bord".equals(classFlag)) { // special button style - not of our interest...
180 logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag);
184 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
185 && "span".equals(elementName)) {
188 logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes);
193 public void handleCloseElement(final @Nullable String elementName, final int line, final int col)
194 throws ParseException {
195 if (this.parserState == ParserState.DATA_ENTRY && "div".equals(elementName)) {
196 this.parserState = ParserState.INIT;
197 StringBuilder sb = this.value;
200 while (sb.length() > 0 && sb.charAt(0) == ' ') {
201 sb = sb.delete(0, 0);
203 if (this.fieldType == FieldType.READ_ONLY || this.fieldType == FieldType.FORM_VALUE) {
204 int len = sb.length();
205 int lids = sb.lastIndexOf(":");
206 if (len - lids == 3) {
207 int lids2 = sb.lastIndexOf(":", lids - 1);
208 if (lids2 > 0 && (lids - lids2 >= 3 && lids - lids2 <= 7)) {
209 // the given value might be a time. validate it
210 String timeCandidate = sb.substring(lids2 + 1).trim();
211 if (timeCandidate.length() == 5 && timeCandidate.matches("[0-9]{2}:[0-9]{2}")) {
216 int fsp = sb.indexOf(" ");
217 if (fsp < 0 || lids < 0 || fsp > lids) {
218 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
221 String shortName = sb.substring(0, fsp).trim();
222 String description = sb.substring(fsp + 1, lids).trim();
223 String value = sb.substring(lids + 1).trim();
224 getApiPageEntry(id, line, col, shortName, description, value);
226 } else if (this.fieldType == FieldType.BUTTON) {
227 String sbt = sb.toString().trim().replaceAll("[\r\n ]+", " ");
228 int fsp = sbt.indexOf(" ");
231 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
234 String shortName = sbt.substring(0, fsp).trim();
235 String description = sbt.substring(fsp + 1).trim();
236 getApiPageEntry(id, line, col, shortName, description, this.buttonValue);
238 } else if (this.fieldType == FieldType.IGNORE) {
241 logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb);
244 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
245 && "span".equals(elementName)) {
248 logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName);
253 public void handleAutoCloseElement(final @Nullable String elementName, final int line, final int col)
254 throws ParseException {
255 logger.debug("Unexpected AutoCloseElement in {}:{}: {}", line, col, elementName);
259 public void handleUnmatchedCloseElement(final @Nullable String elementName, final int line, final int col)
260 throws ParseException {
261 logger.debug("Unexpected UnmatchedCloseElement in {}:{}: {}", line, col, elementName);
265 public void handleDocType(final @Nullable String elementName, final @Nullable String publicId,
266 final @Nullable String systemId, final @Nullable String internalSubset, final int line, final int col)
267 throws ParseException {
268 logger.debug("Unexpected DocType in {}:{}: {}/{}/{}/{}", line, col, elementName, publicId, systemId,
273 public void handleComment(final char @Nullable [] buffer, final int offset, final int len, final int line,
274 final int col) throws ParseException {
275 logger.debug("Unexpected comment in {}:{}: {}", line, col,
276 buffer == null ? "<null>" : new String(buffer, offset, len));
280 public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line,
281 final int col) throws ParseException {
282 logger.debug("Unexpected CDATA in {}:{}: {}", line, col,
283 buffer == null ? "<null>" : new String(buffer, offset, len));
287 public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line,
288 final int col) throws ParseException {
289 if (buffer == null) {
293 if (this.parserState == ParserState.DATA_ENTRY) {
294 // we append it to our current value
295 StringBuilder sb = this.value;
297 sb.append(buffer, offset, len);
299 } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n')
300 || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) {
301 // single newline - ignore/drop it...
303 String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r");
304 logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg);
309 public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding,
310 final @Nullable String standalone, final int line, final int col) throws ParseException {
311 logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone);
315 public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content,
316 final int line, final int col) throws ParseException {
317 logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content);
320 private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description,
322 if (logger.isTraceEnabled()) {
323 logger.trace("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName,
326 if (!this.seenNames.add(shortName)) {
327 logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col,
328 this.fieldType, shortName, description, value);
332 if (value instanceof String && ((String) value).contains("can_busy")) {
333 return; // special state to indicate value currently cannot be retrieved..
335 ApiPageEntry.Type type;
338 ChannelTypeUID ctuid;
339 switch (this.fieldType) {
341 type = Type.SWITCH_BUTTON;
342 state = OnOffType.from(this.buttonValue == ButtonValue.ON);
343 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
344 channelType = "Switch";
348 String vs = (String) value;
349 boolean isOn = "ON".equals(vs) || "EIN".equals(vs); // C.M.I. mixes up languages...
350 if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) {
351 channelType = "Switch";
352 state = OnOffType.from(isOn);
353 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
354 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID;
355 type = Type.READ_ONLY_SWITCH;
357 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
358 type = Type.SWITCH_FORM;
362 // check if we have a numeric value (either with or without unit)
363 String[] valParts = vs.split(" ");
364 // It seems for some wired cases the C.M.I. uses different decimal separators for
365 // different device types. It seems all 'new' X2-Devices use a dot as separator,
366 // for the older pre-X2 devices (i.e. the UVR 1611) we get a comma. So we
367 // we replace all ',' with '.' to check if it's a valid number...
368 String val = valParts[0].replace(',', '.');
369 float bd = Float.parseFloat(val);
370 if (valParts.length == 2) {
371 if ("°C".equals(valParts[1])) {
372 channelType = "Number:Temperature";
373 state = new QuantityType<>(bd, SIUnits.CELSIUS);
374 } else if ("%".equals(valParts[1])) {
375 // channelType = "Number:Percent"; Number:Percent is currently not handled...
376 channelType = "Number:Dimensionless";
377 state = new QuantityType<>(bd, Units.PERCENT);
378 } else if ("Imp".equals(valParts[1])) {
379 // impulses - no idea how to map this to something useful here?
380 channelType = "Number";
381 state = new DecimalType(bd);
382 } else if ("V".equals(valParts[1])) {
383 channelType = "Number:Voltage";
384 state = new QuantityType<>(bd, Units.VOLT);
385 } else if ("A".equals(valParts[1])) {
386 channelType = "Number:Current";
387 state = new QuantityType<>(bd, Units.AMPERE);
388 } else if ("Hz".equals(valParts[1])) {
389 channelType = "Number:Frequency";
390 state = new QuantityType<>(bd, Units.HERTZ);
391 } else if ("kW".equals(valParts[1])) {
392 channelType = "Number:Power";
394 state = new QuantityType<>(bd, Units.WATT);
395 } else if ("kWh".equals(valParts[1])) {
396 channelType = "Number:Energy";
397 state = new QuantityType<>(bd, Units.KILOWATT_HOUR);
398 } else if ("l/h".equals(valParts[1])) {
399 channelType = "Number:VolumetricFlowRate";
401 state = new QuantityType<>(bd, Units.LITRE_PER_MINUTE);
403 channelType = "Number";
404 state = new DecimalType(bd);
405 logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
406 channelType, description, valParts[1]);
409 channelType = "Number";
410 state = new DecimalType(bd);
412 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
413 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
414 type = Type.READ_ONLY_NUMERIC;
417 type = Type.NUMERIC_FORM;
419 } catch (NumberFormatException nfe) {
421 // check for time....
422 String[] valParts = vs.split(":");
423 if (valParts.length == 2) {
424 channelType = "DateTime";
425 // convert it to zonedDateTime with today as date and the
427 var zdt = LocalTime.parse(vs, DateTimeFormatter.ofPattern("HH:mm")).atDate(LocalDate.now())
428 .atZone(ZoneId.systemDefault());
429 state = new DateTimeType(zdt);
430 type = Type.NUMERIC_FORM;
432 // not a number and not time...
433 channelType = "String";
434 state = new StringType(vs);
435 type = Type.STATE_FORM;
437 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
438 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
439 type = Type.READ_ONLY_STATE;
448 // should't happen but we have to add default for the compiler...
451 ApiPageEntry e = this.entries.get(shortName);
453 if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
455 Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
457 ChangerX2Entry cx2e = null;
458 if (this.fieldType == FieldType.FORM_VALUE) {
460 URI uri = this.taCmiSchemaHandler.buildUri("INCLUDE/changerx2.cgi?sadrx2=" + address);
461 final ChangerX2Parser pp = this.taCmiSchemaHandler.parsePage(uri, new ChangerX2Parser(shortName));
462 cx2e = pp.getParsedEntry();
463 } catch (final ParseException | RuntimeException ex) {
464 logger.warn("Error parsing API Scheme: {} ", ex.getMessage(), ex);
465 } catch (final TimeoutException | InterruptedException | ExecutionException ex) {
466 logger.warn("Error loading API Scheme: {} ", ex.getMessage());
469 if (e != null && !channelType.equals(e.channel.getAcceptedItemType())) {
470 // channel type has changed. we have to rebuild the channel.
471 this.channels.remove(channel);
474 if (channel != null && ctuid == null && cx2e != null) {
475 // custom channel type - check if it already exists and recreate when needed...
476 ChannelTypeUID curCtuid = channel.getChannelTypeUID();
477 if (curCtuid == null) {
478 // we have to re-create and re-register the channel uuid
479 logger.debug("Re-Registering channel type UUID for: {} ", shortName);
480 var ct = buildAndRegisterChannelType(shortName, type, cx2e);
481 var channelBuilder = ChannelBuilder.create(channel);
482 channelBuilder.withType(ct.getUID());
483 channel = channelBuilder.build(); // update channel
485 // check if channel uuid still exists and re-carete when needed
486 ChannelType ct = channelTypeProvider.getChannelType(curCtuid, null);
488 buildAndRegisterChannelType(shortName, type, cx2e);
491 } else if (channel == null || !Objects.equals(ctuid, channel.getChannelTypeUID())) {
492 logger.debug("Creating / updating channel {} of type {} for '{}'", shortName, channelType, description);
493 this.configChanged = true;
494 ChannelUID channelUID = new ChannelUID(this.taCmiSchemaHandler.getThing().getUID(), shortName);
495 ChannelBuilder channelBuilder = ChannelBuilder.create(channelUID, channelType);
496 channelBuilder.withLabel(description);
498 channelBuilder.withType(ctuid);
499 } else if (cx2e != null) {
500 ChannelType ct = buildAndRegisterChannelType(shortName, type, cx2e);
502 channelBuilder.withType(ct.getUID());
504 logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName);
506 channel = channelBuilder.build(); // add configuration property...
508 this.configChanged = true;
509 e = new ApiPageEntry(type, channel, address, cx2e, state);
510 this.entries.put(shortName, e);
515 this.channels.add(e.channel);
516 // only update the state when there was no state change sent to C.M.I. after we started
517 // polling the state. It might deliver the previous / old state.
518 if (e.getLastCommandTS() < this.statusRequestStartTS) {
519 Number updatePolicyI = (Number) e.channel.getConfiguration().get("updatePolicy");
520 int updatePolicy = updatePolicyI == null ? 0 : updatePolicyI.intValue();
521 switch (updatePolicy) {
524 // we do 'On-Fetch' update when channel is changeable, otherwise 'On-Change'
530 if (isNewEntry || !state.equals(e.getLastState())) {
531 e.setLastState(state);
532 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
535 case READ_ONLY_NUMERIC:
536 case READ_ONLY_STATE:
537 case READ_ONLY_SWITCH:
538 e.setLastState(state);
539 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
544 e.setLastState(state);
545 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
548 if (isNewEntry || !state.equals(e.getLastState())) {
549 e.setLastState(state);
550 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
557 private ChannelType buildAndRegisterChannelType(String shortName, Type type, ChangerX2Entry cx2e) {
558 StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create().withReadOnly(type.readOnly);
560 switch (cx2e.optionType) {
563 String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN);
564 if (min != null && !min.trim().isEmpty()) {
565 sdb.withMinimum(new BigDecimal(min));
567 String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX);
568 if (max != null && !max.trim().isEmpty()) {
569 sdb.withMaximum(new BigDecimal(max));
571 String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP);
572 if (step != null && !step.trim().isEmpty()) {
573 sdb.withStep(new BigDecimal(step));
578 for (Entry<String, @Nullable String> entry : cx2e.options.entrySet()) {
579 String val = entry.getValue();
581 sdb.withOption(new StateOption(val, entry.getKey()));
586 itemType = "DateTime";
589 throw new IllegalStateException("Unhandled OptionType: " + cx2e.optionType);
591 ChannelTypeBuilder<?> ctb = ChannelTypeBuilder
592 .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType)
593 .withDescription("Auto-created for " + shortName).withStateDescriptionFragment(sdb.build());
595 // add config description URI
596 URI cdu = configDescriptionUriAPISchemaDefaults;
598 ctb = ctb.withConfigDescriptionURI(cdu);
601 ChannelType ct = ctb.build();
602 channelTypeProvider.addChannelType(ct);
606 protected boolean isConfigChanged() {
607 return this.configChanged;
610 protected List<Channel> getChannels() {