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.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 {
138 logger.debug("Unexpected StandaloneElement in {}:{}: {} [{}]", line, col, elementName, attributes);
142 @NonNullByDefault({})
143 public void handleOpenElement(final @Nullable String elementName, final @Nullable Map<String, String> attributes,
144 final int line, final int col) throws ParseException {
145 if (this.parserState == ParserState.INIT && "div".equals(elementName)) {
146 this.parserState = ParserState.DATA_ENTRY;
148 if (attributes == null) {
153 this.id = attributes.get("id");
154 this.address = attributes.get("adresse");
155 classFlags = attributes.get("class");
157 this.fieldType = FieldType.READ_ONLY;
158 this.value = new StringBuilder();
159 this.buttonValue = ButtonValue.UNKNOWN;
160 if (classFlags != null && StringUtil.isNotBlank(classFlags)) {
161 String[] classFlagList = classFlags.split("[ \n\r]");
162 for (String classFlag : classFlagList) {
163 if ("changex2".equals(classFlag)) {
164 this.fieldType = FieldType.FORM_VALUE;
165 } else if ("buttonx2".equals(classFlag) || "taster".equals(classFlag)) {
166 this.fieldType = FieldType.BUTTON;
167 } else if ("visible0".equals(classFlag)) {
168 this.buttonValue = ButtonValue.OFF;
169 } else if ("visible1".equals(classFlag)) {
170 this.buttonValue = ButtonValue.ON;
171 } else if ("durchsichtig".equals(classFlag)) { // link
172 this.fieldType = FieldType.IGNORE;
173 } else if ("bord".equals(classFlag)) { // special button style - not of our interest...
175 logger.debug("Unhanndled class in {}:{}:{}: '{}' ", id, line, col, classFlag);
179 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
180 && "span".equals(elementName)) {
183 logger.debug("Unexpected OpenElement in {}:{}: {} [{}]", line, col, elementName, attributes);
188 public void handleCloseElement(final @Nullable String elementName, final int line, final int col)
189 throws ParseException {
190 if (this.parserState == ParserState.DATA_ENTRY && "div".equals(elementName)) {
191 this.parserState = ParserState.INIT;
192 StringBuilder sb = this.value;
195 while (sb.length() > 0 && sb.charAt(0) == ' ') {
196 sb = sb.delete(0, 0);
198 if (this.fieldType == FieldType.READ_ONLY || this.fieldType == FieldType.FORM_VALUE) {
199 int lids = sb.lastIndexOf(":");
200 int fsp = sb.indexOf(" ");
201 if (fsp < 0 || lids < 0 || fsp > lids) {
202 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
205 String shortName = sb.substring(0, fsp).trim();
206 String description = sb.substring(fsp + 1, lids).trim();
207 String value = sb.substring(lids + 1).trim();
208 getApiPageEntry(id, line, col, shortName, description, value);
210 } else if (this.fieldType == FieldType.BUTTON) {
211 String sbt = sb.toString().trim().replaceAll("[\r\n ]+", " ");
212 int fsp = sbt.indexOf(" ");
215 logger.debug("Invalid format for setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType,
218 String shortName = sbt.substring(0, fsp).trim();
219 String description = sbt.substring(fsp + 1).trim();
220 getApiPageEntry(id, line, col, shortName, description, this.buttonValue);
222 } else if (this.fieldType == FieldType.IGNORE) {
225 logger.debug("Unhandled setting {}:{}:{} [{}] : {}", id, line, col, this.fieldType, sb);
228 } else if (this.parserState == ParserState.DATA_ENTRY && this.fieldType == FieldType.BUTTON
229 && "span".equals(elementName)) {
232 logger.debug("Unexpected CloseElement in {}:{}: {}", line, col, elementName);
237 public void handleAutoCloseElement(final @Nullable String elementName, final int line, final int col)
238 throws ParseException {
239 logger.debug("Unexpected AutoCloseElement in {}:{}: {}", line, col, elementName);
243 public void handleUnmatchedCloseElement(final @Nullable String elementName, final int line, final int col)
244 throws ParseException {
245 logger.debug("Unexpected UnmatchedCloseElement in {}:{}: {}", line, col, elementName);
249 public void handleDocType(final @Nullable String elementName, final @Nullable String publicId,
250 final @Nullable String systemId, final @Nullable String internalSubset, final int line, final int col)
251 throws ParseException {
252 logger.debug("Unexpected DocType in {}:{}: {}/{}/{}/{}", line, col, elementName, publicId, systemId,
257 public void handleComment(final char @Nullable [] buffer, final int offset, final int len, final int line,
258 final int col) throws ParseException {
259 logger.debug("Unexpected comment in {}:{}: {}", line, col,
260 buffer == null ? "<null>" : new String(buffer, offset, len));
264 public void handleCDATASection(final char @Nullable [] buffer, final int offset, final int len, final int line,
265 final int col) throws ParseException {
266 logger.debug("Unexpected CDATA in {}:{}: {}", line, col,
267 buffer == null ? "<null>" : new String(buffer, offset, len));
271 public void handleText(final char @Nullable [] buffer, final int offset, final int len, final int line,
272 final int col) throws ParseException {
273 if (buffer == null) {
277 if (this.parserState == ParserState.DATA_ENTRY) {
278 // we append it to our current value
279 StringBuilder sb = this.value;
281 sb.append(buffer, offset, len);
283 } else if (this.parserState == ParserState.INIT && ((len == 1 && buffer[offset] == '\n')
284 || (len == 2 && buffer[offset] == '\r' && buffer[offset + 1] == '\n'))) {
285 // single newline - ignore/drop it...
287 String msg = new String(buffer, offset, len).replace("\n", "\\n").replace("\r", "\\r");
288 logger.debug("Unexpected Text {}:{}: ParserState: {} ({}) `{}`", line, col, parserState, len, msg);
293 public void handleXmlDeclaration(final @Nullable String version, final @Nullable String encoding,
294 final @Nullable String standalone, final int line, final int col) throws ParseException {
295 logger.debug("Unexpected XML Declaration {}:{}: {} {} {}", line, col, version, encoding, standalone);
299 public void handleProcessingInstruction(final @Nullable String target, final @Nullable String content,
300 final int line, final int col) throws ParseException {
301 logger.debug("Unexpected ProcessingInstruction {}:{}: {} {}", line, col, target, content);
304 private void getApiPageEntry(@Nullable String id2, int line, int col, String shortName, String description,
306 if (logger.isTraceEnabled()) {
307 logger.trace("Found parameter {}:{}:{} [{}] : {} \"{}\" = {}", id, line, col, this.fieldType, shortName,
310 if (!this.seenNames.add(shortName)) {
311 logger.warn("Found duplicate parameter '{}' in {}:{}:{} [{}] : {} \"{}\" = {}", shortName, id, line, col,
312 this.fieldType, shortName, description, value);
316 if (value instanceof String && ((String) value).contains("can_busy")) {
317 return; // special state to indicate value currently cannot be retrieved..
319 ApiPageEntry.Type type;
322 ChannelTypeUID ctuid;
323 switch (this.fieldType) {
325 type = Type.SWITCH_BUTTON;
326 state = OnOffType.from(this.buttonValue == ButtonValue.ON);
327 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
328 channelType = "Switch";
332 String vs = (String) value;
333 boolean isOn = "ON".equals(vs) || "EIN".equals(vs); // C.M.I. mixes up languages...
334 if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) {
335 channelType = "Switch";
336 state = OnOffType.from(isOn);
337 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
338 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID;
339 type = Type.READ_ONLY_SWITCH;
341 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
342 type = Type.SWITCH_FORM;
346 // check if we have a numeric value (either with or without unit)
347 String[] valParts = vs.split(" ");
348 // It seems for some wired cases the C.M.I. uses different decimal separators for
349 // different device types. It seems all 'new' X2-Devices use a dot as separator,
350 // for the older pre-X2 devices (i.e. the UVR 1611) we get a comma. So we
351 // we replace all ',' with '.' to check if it's a valid number...
352 String val = valParts[0].replace(',', '.');
353 BigDecimal bd = new BigDecimal(val);
354 if (valParts.length == 2) {
355 if ("°C".equals(valParts[1])) {
356 channelType = "Number:Temperature";
357 state = new QuantityType<>(bd, SIUnits.CELSIUS);
358 } else if ("%".equals(valParts[1])) {
359 // channelType = "Number:Percent"; Number:Percent is currently not handled...
360 channelType = "Number:Dimensionless";
361 state = new QuantityType<>(bd, Units.PERCENT);
362 } else if ("Imp".equals(valParts[1])) {
363 // impulses - no idea how to map this to something useful here?
364 channelType = "Number";
365 state = new DecimalType(bd);
366 } else if ("V".equals(valParts[1])) {
367 channelType = "Number:Voltage";
368 state = new QuantityType<>(bd, Units.VOLT);
369 } else if ("A".equals(valParts[1])) {
370 channelType = "Number:Current";
371 state = new QuantityType<>(bd, Units.AMPERE);
372 } else if ("Hz".equals(valParts[1])) {
373 channelType = "Number:Frequency";
374 state = new QuantityType<>(bd, Units.HERTZ);
375 } else if ("kW".equals(valParts[1])) {
376 channelType = "Number:Power";
377 bd = bd.multiply(new BigDecimal(1000));
378 state = new QuantityType<>(bd, Units.WATT);
379 } else if ("kWh".equals(valParts[1])) {
380 channelType = "Number:Power";
381 bd = bd.multiply(new BigDecimal(1000));
382 state = new QuantityType<>(bd, Units.KILOWATT_HOUR);
383 } else if ("l/h".equals(valParts[1])) {
384 channelType = "Number:Volume";
385 bd = bd.divide(new BigDecimal(60));
386 state = new QuantityType<>(bd, Units.LITRE_PER_MINUTE);
388 channelType = "Number";
389 state = new DecimalType(bd);
390 logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
391 channelType, description, valParts[1]);
394 channelType = "Number";
395 state = new DecimalType(bd);
397 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
398 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
399 type = Type.READ_ONLY_NUMERIC;
402 type = Type.NUMERIC_FORM;
404 } catch (NumberFormatException nfe) {
406 channelType = "String";
407 if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
408 ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
409 type = Type.READ_ONLY_STATE;
412 type = Type.STATE_FORM;
414 state = new StringType(vs);
422 // should't happen but we have to add default for the compiler...
425 ApiPageEntry e = this.entries.get(shortName);
427 if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
429 Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
431 ChangerX2Entry cx2e = null;
432 if (this.fieldType == FieldType.FORM_VALUE) {
434 URI uri = this.taCmiSchemaHandler.buildUri("INCLUDE/changerx2.cgi?sadrx2=" + address);
435 final ChangerX2Parser pp = this.taCmiSchemaHandler.parsePage(uri, new ChangerX2Parser(shortName));
436 cx2e = pp.getParsedEntry();
437 } catch (final ParseException | RuntimeException ex) {
438 logger.warn("Error parsing API Scheme: {} ", ex.getMessage(), ex);
439 } catch (final TimeoutException | InterruptedException | ExecutionException ex) {
440 logger.warn("Error loading API Scheme: {} ", ex.getMessage());
443 if (channel == null || !Objects.equals(ctuid, channel.getChannelTypeUID())) {
444 logger.debug("Creating / updating channel {} of type {} for '{}'", shortName, channelType, description);
445 this.configChanged = true;
446 ChannelUID channelUID = new ChannelUID(this.taCmiSchemaHandler.getThing().getUID(), shortName);
447 ChannelBuilder channelBuilder = ChannelBuilder.create(channelUID, channelType);
448 channelBuilder.withLabel(description);
450 channelBuilder.withType(ctuid);
451 } else if (cx2e != null) {
452 ChannelType ct = buildAndRegisterChannelType(shortName, type, cx2e);
454 channelBuilder.withType(ct.getUID());
456 logger.warn("Error configurating channel for {}: channeltype cannot be determined!", shortName);
458 channel = channelBuilder.build(); // add configuration property...
459 } else if (ctuid == null && cx2e != null) {
460 // custom channel type - check if it already exists and recreate when needed...
461 ChannelTypeUID curCtuid = channel.getChannelTypeUID();
462 if (curCtuid != null) {
463 ChannelType ct = channelTypeProvider.getChannelType(curCtuid, null);
465 buildAndRegisterChannelType(shortName, type, cx2e);
469 this.configChanged = true;
470 e = new ApiPageEntry(type, channel, address, cx2e, state);
471 this.entries.put(shortName, e);
476 this.channels.add(e.channel);
477 // only update the state when there was no state change sent to C.M.I. after we started
478 // polling the state. It might deliver the previous / old state.
479 if (e.getLastCommandTS() < this.statusRequestStartTS) {
480 Number updatePolicyI = (Number) e.channel.getConfiguration().get("updatePolicy");
481 int updatePolicy = updatePolicyI == null ? 0 : updatePolicyI.intValue();
482 switch (updatePolicy) {
485 // we do 'On-Fetch' update when channel is changeable, otherwise 'On-Change'
491 if (isNewEntry || !state.equals(e.getLastState())) {
492 e.setLastState(state);
493 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
496 case READ_ONLY_NUMERIC:
497 case READ_ONLY_STATE:
498 case READ_ONLY_SWITCH:
499 e.setLastState(state);
500 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
505 e.setLastState(state);
506 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
509 if (isNewEntry || !state.equals(e.getLastState())) {
510 e.setLastState(state);
511 this.taCmiSchemaHandler.updateState(e.channel.getUID(), state);
518 private ChannelType buildAndRegisterChannelType(String shortName, Type type, ChangerX2Entry cx2e) {
519 StateDescriptionFragmentBuilder sdb = StateDescriptionFragmentBuilder.create().withReadOnly(type.readOnly);
521 switch (cx2e.optionType) {
524 String min = cx2e.options.get(ChangerX2Entry.NUMBER_MIN);
525 if (min != null && !min.trim().isEmpty()) {
526 sdb.withMinimum(new BigDecimal(min));
528 String max = cx2e.options.get(ChangerX2Entry.NUMBER_MAX);
529 if (max != null && !max.trim().isEmpty()) {
530 sdb.withMaximum(new BigDecimal(max));
532 String step = cx2e.options.get(ChangerX2Entry.NUMBER_STEP);
533 if (step != null && !step.trim().isEmpty()) {
534 sdb.withStep(new BigDecimal(step));
539 for (Entry<String, @Nullable String> entry : cx2e.options.entrySet()) {
540 String val = entry.getValue();
542 sdb.withOption(new StateOption(val, entry.getKey()));
547 throw new IllegalStateException();
549 ChannelTypeBuilder<?> ctb = ChannelTypeBuilder
550 .state(new ChannelTypeUID(TACmiBindingConstants.BINDING_ID, shortName), shortName, itemType)
551 .withDescription("Auto-created for " + shortName).withStateDescriptionFragment(sdb.build());
553 // add config description URI
554 URI cdu = configDescriptionUriAPISchemaDefaults;
556 ctb = ctb.withConfigDescriptionURI(cdu);
559 ChannelType ct = ctb.build();
560 channelTypeProvider.addChannelType(ct);
564 protected boolean isConfigChanged() {
565 return this.configChanged;
568 protected List<Channel> getChannels() {