import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
+import java.util.regex.Pattern;
+
+import javax.measure.Unit;
import org.attoparser.ParseException;
import org.attoparser.simple.AbstractSimpleMarkupHandler;
import org.openhab.binding.tacmi.internal.TACmiBindingConstants;
import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
import org.openhab.binding.tacmi.internal.schema.ApiPageEntry.Type;
+import org.openhab.binding.tacmi.internal.schema.TACmiSchemaHandler.UnitAndType;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
-import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.CurrencyUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.types.State;
import org.openhab.core.types.StateDescriptionFragmentBuilder;
import org.openhab.core.types.StateOption;
+import org.openhab.core.types.util.UnitUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// Time stamp when status request was started.
private final long statusRequestStartTS;
private static @Nullable URI configDescriptionUriAPISchemaDefaults;
+ private final Pattern timePattern = Pattern.compile("[0-9]{2}:[0-9]{2}");
+ private final Pattern durationPattern = Pattern.compile("([0-9\\.]{1,4}[dhms] ?)+");
+ // needed for unit rewrite. it seems OHM is not registered as symbol in the units.
public ApiPageParser(TACmiSchemaHandler taCmiSchemaHandler, Map<String, ApiPageEntry> entries,
TACmiChannelTypeProvider channelTypeProvider) {
super();
if (lids2 > 0 && (lids - lids2 >= 3 && lids - lids2 <= 7)) {
// the given value might be a time. validate it
String timeCandidate = sb.substring(lids2 + 1).trim();
- if (timeCandidate.length() == 5 && timeCandidate.matches("[0-9]{2}:[0-9]{2}")) {
+ if (timeCandidate.length() == 5 && timePattern.matcher(timeCandidate).matches()) {
lids = lids2;
}
}
return; // special state to indicate value currently cannot be retrieved..
}
ApiPageEntry.Type type;
+ Unit<?> unit;
State state;
String channelType;
ChannelTypeUID ctuid;
state = OnOffType.from(this.buttonValue == ButtonValue.ON);
ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RW_UID;
channelType = "Switch";
+ unit = null;
break;
case READ_ONLY:
case FORM_VALUE:
if (isOn || "OFF".equals(vs) || "AUS".equals(vs)) {
channelType = "Switch";
state = OnOffType.from(isOn);
+ unit = null;
if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_SWITCH_RO_UID;
type = Type.READ_ONLY_SWITCH;
String val = valParts[0].replace(',', '.');
float bd = Float.parseFloat(val);
if (valParts.length == 2) {
- if ("°C".equals(valParts[1])) {
- channelType = "Number:Temperature";
- state = new QuantityType<>(bd, SIUnits.CELSIUS);
- } else if ("%".equals(valParts[1])) {
- // channelType = "Number:Percent"; Number:Percent is currently not handled...
- channelType = "Number:Dimensionless";
- state = new QuantityType<>(bd, Units.PERCENT);
- } else if ("Imp".equals(valParts[1])) {
- // impulses - no idea how to map this to something useful here?
+ var unitStr = valParts[1];
+ var unitData = taCmiSchemaHandler.unitsCache.get(unitStr);
+ if (unitData == null) {
+ // we try to lookup the unit given by TA.
+ try {
+ // Special rewrite for electrical resistance measurements
+ // U+2126 is the 'real' OHM sign, but it seems to be registered as Greek Omega
+ // (U+03A9) in the units
+ String unitStrRepl = unitStr.replace((char) 0x2126, (char) 0x03A9);
+ // we build a 'normalized' value for parsing in QuantityType.
+ var qt = new QuantityType<>(val + " " + unitStrRepl, Locale.US);
+ // Just use the unit. We need to remember the unit in the channel data because we
+ // need to send data to the C.M.I. in the same unit
+ unit = qt.getUnit();
+ channelType = "Number:" + UnitUtils.getDimensionName(unit);
+ unitData = new UnitAndType(unit, channelType);
+ } catch (IllegalArgumentException iae) {
+ // failed to get unit...
+ if ("Imp".equals(unitStr) || "€$".contains(unitStr)) {
+ // special case
+ unitData = taCmiSchemaHandler.SPECIAL_MARKER;
+ } else {
+ unitData = taCmiSchemaHandler.NULL_MARKER;
+ logger.warn(
+ "Unhandled UoM '{}' - seen on channel {} '{}'; Message from QuantityType: {}",
+ valParts[1], shortName, description, iae.getMessage());
+ }
+ }
+ taCmiSchemaHandler.unitsCache.put(unitStr, unitData);
+ }
+ if (unitData == taCmiSchemaHandler.NULL_MARKER) {
+ // no UoM mappable - just send value
channelType = "Number";
+ unit = null;
state = new DecimalType(bd);
- } else if ("V".equals(valParts[1])) {
- channelType = "Number:Voltage";
- state = new QuantityType<>(bd, Units.VOLT);
- } else if ("A".equals(valParts[1])) {
- channelType = "Number:Current";
- state = new QuantityType<>(bd, Units.AMPERE);
- } else if ("Hz".equals(valParts[1])) {
- channelType = "Number:Frequency";
- state = new QuantityType<>(bd, Units.HERTZ);
- } else if ("kW".equals(valParts[1])) {
- channelType = "Number:Power";
- bd = bd *= 1000;
- state = new QuantityType<>(bd, Units.WATT);
- } else if ("kWh".equals(valParts[1])) {
- channelType = "Number:Energy";
- state = new QuantityType<>(bd, Units.KILOWATT_HOUR);
- } else if ("l/h".equals(valParts[1])) {
- channelType = "Number:VolumetricFlowRate";
- bd = bd /= 60;
- state = new QuantityType<>(bd, Units.LITRE_PER_MINUTE);
+ } else if (unitData == taCmiSchemaHandler.SPECIAL_MARKER) {
+ // special handling for unknown UoM
+ if ("Imp".equals(unitStr)) { // Number of Pulses
+ // impulses - no idea how to map this to something useful here?
+ channelType = "Number";
+ unit = null;
+ state = new DecimalType(bd);
+ } else if ("€$".contains(unitStr)) { // Currency's
+ var currency = "€".equals(valParts[1]) ? "EUR" : "USD";
+ unit = CurrencyUnits.getInstance().getUnit(currency);
+ if (unit == null) {
+ logger.trace("Currency {} is unknown, falling back to DecimalType", currency);
+ state = new DecimalType(bd);
+ channelType = "Number:Dimensionless";
+ } else {
+ state = new QuantityType<>(bd, unit);
+ channelType = "Number:" + UnitUtils.getDimensionName(unit);
+ }
+ } else {
+ throw new IllegalStateException("BUG: " + unitStr + " is not mapped!");
+ }
} else {
- channelType = "Number";
- state = new DecimalType(bd);
- logger.debug("Unhandled UoM for channel {} of type {} for '{}': {}", shortName,
- channelType, description, valParts[1]);
+ channelType = unitData.channelType();
+ unit = unitData.unit();
+ state = new QuantityType<>(bd, unit);
}
} else {
channelType = "Number";
+ unit = null;
state = new DecimalType(bd);
}
if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
}
} catch (NumberFormatException nfe) {
ctuid = null;
- // check for time....
+ unit = null;
+ // check for time - 'Time' field
String[] valParts = vs.split(":");
if (valParts.length == 2) {
- channelType = "DateTime";
// convert it to zonedDateTime with today as date and the
// default timezone.
var zdt = LocalTime.parse(vs, DateTimeFormatter.ofPattern("HH:mm")).atDate(LocalDate.now())
.atZone(ZoneId.systemDefault());
state = new DateTimeType(zdt);
+ channelType = "DateTime";
type = Type.NUMERIC_FORM;
+ if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
+ ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_DATE_TIME_RO_UID;
+ type = Type.READ_ONLY_NUMERIC;
+ }
} else {
- // not a number and not time...
- channelType = "String";
- state = new StringType(vs);
- type = Type.STATE_FORM;
- }
- if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
- ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
- type = Type.READ_ONLY_STATE;
+ // durations are a set of '000d 00h 00m 00.0s` fields
+ var durMatcher = durationPattern.matcher(vs);
+ if (durMatcher.matches()) {
+ // we have a duration
+ var parts = vs.split(" ");
+ float time = 0;
+ // sum up parts to a time
+ for (var timePart : parts) {
+ // last char is time unit, part before is time.
+ // for seconds it could be a fraction;
+ var pl = timePart.length();
+ var tu = timePart.charAt(pl - 1);
+ var tv = Float.parseFloat(timePart.substring(0, pl - 1));
+
+ time += switch (tu) {
+ case 'd' -> tv * 86400; // days - 24h*60m*60s
+ case 'h' -> tv * 3600; // hours - 60m*60s
+ case 'm' -> tv * 60; // minutes - 60s
+ case 's' -> tv; // seconds - pass value
+ default -> throw new IllegalArgumentException(
+ "Unexpected time unit " + tu + " in " + vs);
+ };
+ }
+ state = new QuantityType<>(time, Units.SECOND);
+ channelType = "Number:Time";
+ type = Type.TIME_PERIOD;
+ if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
+ ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
+ type = Type.READ_ONLY_NUMERIC;
+ }
+ } else {
+ // not a number and not time or duration
+ channelType = "String";
+ state = new StringType(vs);
+ type = Type.STATE_FORM;
+ if (this.fieldType == FieldType.READ_ONLY || this.address == null) {
+ ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
+ type = Type.READ_ONLY_STATE;
+ }
+ }
}
}
}
}
ApiPageEntry e = this.entries.get(shortName);
boolean isNewEntry;
- if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())) {
+ if (e == null || e.type != type || !channelType.equals(e.channel.getAcceptedItemType())
+ || !Objects.equals(e.unit, unit)) {
@Nullable
Channel channel = this.taCmiSchemaHandler.getThing().getChannel(shortName);
@Nullable
} catch (final TimeoutException | InterruptedException | ExecutionException ex) {
logger.warn("Error loading API Scheme: {} ", ex.getMessage());
}
+ if (cx2e == null) {
+ // switch channel to readOnly
+ this.fieldType = FieldType.READ_ONLY;
+ if (type == Type.NUMERIC_FORM || type == Type.TIME_PERIOD) {
+ if ("DateTime".equals(channelType)) {
+ ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_DATE_TIME_RO_UID;
+ } else {
+ ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_NUMERIC_RO_UID;
+ }
+ type = Type.READ_ONLY_NUMERIC;
+ } else {
+ ctuid = TACmiBindingConstants.CHANNEL_TYPE_SCHEME_STATE_RO_UID;
+ type = Type.READ_ONLY_STATE;
+ }
+
+ }
}
if (e != null && !channelType.equals(e.channel.getAcceptedItemType())) {
// channel type has changed. we have to rebuild the channel.
channel = channelBuilder.build(); // add configuration property...
}
this.configChanged = true;
- e = new ApiPageEntry(type, channel, address, cx2e, state);
+ e = new ApiPageEntry(type, channel, unit, address, cx2e, state);
this.entries.put(shortName, e);
isNewEntry = true;
} else {
// we do 'On-Fetch' update when channel is changeable, otherwise 'On-Change'
switch (e.type) {
case NUMERIC_FORM:
+ case TIME_PERIOD:
case STATE_FORM:
case SWITCH_BUTTON:
case SWITCH_FORM:
}
break;
case TIME:
- itemType = "DateTime";
+ if (type == Type.TIME_PERIOD) {
+ itemType = "Number";
+ } else {
+ itemType = "DateTime";
+ }
break;
default:
throw new IllegalStateException("Unhandled OptionType: " + cx2e.optionType);
col, attributes);
}
}
- } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT)
- && "input".equals(elementName) && "changetotimeh".equals(id)) {
+ } else if ((this.parserState == ParserState.INIT || this.parserState == ParserState.INPUT
+ || this.parserState == ParserState.INPUT_DATA) // input tags are not closed properly
+ && "input".equals(elementName) && id != null && id.startsWith("changetotime")) {
this.parserState = ParserState.INPUT_DATA;
+ var timeType = id.charAt(12);
if (attributes != null) {
this.optionFieldName = attributes.get("name");
- String type = attributes.get("type");
if ("number".equals(attributes.get("type"))) {
this.optionType = OptionType.TIME;
- // validate hour limits
- if (!"0".equals(attributes.get(ChangerX2Entry.NUMBER_MIN))
- || !"24".equals(attributes.get(ChangerX2Entry.NUMBER_MAX))) {
- logger.warn(
- "Error parsing options for {}: Unexpected MIN/MAX values for hour input field in {}:{}: {}",
- channelName, line, col, attributes);
+ switch (timeType) {
+ case 'h':
+ String maxHourValue = attributes.get(ChangerX2Entry.NUMBER_MAX);
+ // validate hour limits; for 'time' max is 24, for time period max is 23 ...
+ if (!"0".equals(attributes.get(ChangerX2Entry.NUMBER_MIN))
+ || (!"24".equals(maxHourValue) && !"23".equals(maxHourValue))) {
+ logger.warn(
+ "Error parsing options for {}: Unexpected MIN/MAX values for hour input field in {}:{}: {}",
+ channelName, line, col, attributes);
+ }
+ break;
+ case 'm':
+ case 's':
+ if (!"0".equals(attributes.get(ChangerX2Entry.NUMBER_MIN))
+ || !"59".equals(attributes.get(ChangerX2Entry.NUMBER_MAX))) {
+ logger.warn(
+ "Error parsing options for {}: Unexpected MIN/MAX values for minute input field in {}:{}: {}",
+ channelName, line, col, attributes);
+ }
+ break;
+ case 'z': // this is 'zehntelsekunde' - tenth of a second
+ if (!"0".equals(attributes.get(ChangerX2Entry.NUMBER_MIN))
+ || !"59.9".equals(attributes.get(ChangerX2Entry.NUMBER_MAX))) {
+ logger.warn(
+ "Error parsing options for {}: Unexpected MIN/MAX values for minute input field in {}:{}: {}",
+ channelName, line, col, attributes);
+ }
+ break;
+ case 'd': // for day's we don't validate. usually min = 0 and no max is given
+ break;
+ default:
+ throw new IllegalArgumentException(
+ "Unexpected timeType " + timeType + " during time span input field parsing");
}
- ;
- } else {
- logger.warn("Error parsing options for {}: Unhandled input field in {}:{}: {}", channelName, line,
- col, attributes);
- }
- }
- } else if ((this.parserState == ParserState.INPUT_DATA || this.parserState == ParserState.INPUT)
- && "input".equals(elementName) && "changetotimem".equals(id)) {
- this.parserState = ParserState.INPUT_DATA;
- if (attributes != null) {
- if ("number".equals(attributes.get("type"))) {
- this.optionType = OptionType.TIME;
- if (!"0".equals(attributes.get(ChangerX2Entry.NUMBER_MIN))
- || !"59".equals(attributes.get(ChangerX2Entry.NUMBER_MAX))) {
- logger.warn(
- "Error parsing options for {}: Unexpected MIN/MAX values for minute input field in {}:{}: {}",
- channelName, line, col, attributes);
+ var timeParts = this.options.get(ChangerX2Entry.TIME_PERIOD_PARTS);
+ if (timeParts == null) {
+ timeParts = "" + timeType;
+ } else {
+ timeParts = timeParts + timeType;
}
- ;
+ this.options.put(ChangerX2Entry.TIME_PERIOD_PARTS, timeParts);
} else {
+
logger.warn("Error parsing options for {}: Unhandled input field in {}:{}: {}", channelName, line,
col, attributes);
}
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import javax.measure.MetricPrefix;
+import javax.measure.Unit;
+
import org.attoparser.ParseException;
import org.attoparser.config.ParseConfiguration;
import org.attoparser.config.ParseConfiguration.ElementBalancing;
import org.openhab.binding.tacmi.internal.TACmiChannelTypeProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
private @Nullable ScheduledFuture<?> scheduledFuture;
private final ParseConfiguration noRestrictions;
+ // entry of the units lookup cache
+ record UnitAndType(Unit<?> unit, String channelType) {
+ }
+
+ // this is the units lookup cache.
+ protected final Map<String, UnitAndType> unitsCache = new ConcurrentHashMap<>();
+ // marks an entry with known un-resolveable unit
+ protected final UnitAndType NULL_MARKER = new UnitAndType(Units.ONE, "");
+ // marks an entry with special handling - i.e. 'Imp'
+ protected final UnitAndType SPECIAL_MARKER = new UnitAndType(Units.ONE, "s");
+
public TACmiSchemaHandler(final Thing thing, final HttpClient httpClient,
final TACmiChannelTypeProvider channelTypeProvider) {
super(thing);
ChangerX2Entry cx2en = e.changerX2Entry;
if (cx2en != null) {
String val;
- if (command instanceof Number qt) {
+ if (command instanceof QuantityType qt) {
+ float value;
+ var taUnit = e.unit;
+ if (taUnit != null) {
+ // we try to convert to the unit TA expects for this channel
+ @SuppressWarnings("unchecked")
+ @Nullable
+ QuantityType<?> qtConverted = qt.toUnit(taUnit);
+ if (qtConverted == null) {
+ logger.debug("Faild to convert unit {} to unit {} for command on channel {}",
+ qt.getUnit(), taUnit, channelUID);
+ value = qt.floatValue();
+ } else {
+ value = qtConverted.floatValue();
+ }
+
+ } else {
+ // send raw value when there is no unit for this channel
+ value = qt.floatValue();
+ }
+ val = String.format(Locale.US, "%.2f", value);
+ } else if (command instanceof Number qt) {
val = String.format(Locale.US, "%.2f", qt.floatValue());
} else if (command instanceof DateTimeType dtt) {
// time is transferred as minutes since midnight...
return;
}
break;
+ case TIME_PERIOD:
+ ChangerX2Entry cx2enTime = e.changerX2Entry;
+ if (cx2enTime != null) {
+ long timeValMSec;
+ if (command instanceof QuantityType qt) {
+ @SuppressWarnings("unchecked")
+ QuantityType<?> seconds = qt.toUnit(MetricPrefix.MILLI(Units.SECOND));
+ if (seconds != null) {
+ timeValMSec = seconds.longValue();
+ } else {
+ // fallback - assume we have a time in milliseconds
+ timeValMSec = qt.longValue();
+ }
+ } else if (command instanceof Number qt) {
+ // fallback - assume we have a time in milliseconds
+ timeValMSec = qt.longValue();
+ } else {
+ throw new IllegalArgumentException(
+ "Command " + command + " cannot be converted to a proper Timespan!");
+ }
+ String val;
+ // TA has three different time periods. One is based on full seconds, the second on tenths of
+ // seconds and the third on minutes. We decide on the basis of the form fields provided during the
+ // ChangerX2 scan.
+ String parts = cx2enTime.options.get(ChangerX2Entry.TIME_PERIOD_PARTS);
+ if (parts == null || parts.indexOf('z') >= 0) {
+ // tenths of seconds
+ val = String.format(Locale.US, "%.1f", timeValMSec / 1000d);
+ } else if (parts.indexOf('s') >= 0) {
+ // seconds
+ val = String.format(Locale.US, "%d", timeValMSec / 1000);
+ } else {
+ // minutes
+ val = String.format(Locale.US, "%d", timeValMSec / 60000);
+ }
+ reqUpdate = prepareRequest(
+ buildUri("INCLUDE/change.cgi?changeadrx2=" + cx2enTime.address + "&changetox2=" + val));
+ reqUpdate.header(HttpHeader.REFERER, this.serverBase + "schema.html"); // required...
+ } else {
+ logger.debug("Got command for uninitalized channel {}: {}", channelUID, command);
+ return;
+ }
+ break;
case READ_ONLY_NUMERIC:
case READ_ONLY_STATE:
case READ_ONLY_SWITCH: