[tuya] Add support for quantities (#18710)

* Add support for quantities

Signed-off-by: Mike Jagdis <mjagdis@eris-associates.co.uk>
This commit is contained in:
mjagdis
2025-07-13 16:02:14 +02:00
committed by GitHub
parent 6e82872c20
commit 9976530ad8
9 changed files with 561326 additions and 84326 deletions
+2 -2
View File
@@ -108,9 +108,9 @@ The `dp2` parameter identifies the ON/OFF switch that is usually available on di
The `reversed` parameter changes the direction of the scale (e.g. 0 becomes 100, 100 becomes 0).
It defaults to `false`.
### Type `number`
### Type `number/quantity`
The `number` channel has two additional mandatory parameters `min` and `max`.
The `number` and `quantity` channels have two additional mandatory parameters `min` and `max`.
The `min` and `max` parameters define the range allowed (e.g. 0-86400 for turn-off "countdown").
### Type `string`
@@ -54,6 +54,7 @@ public class TuyaBindingConstants {
public static final ChannelTypeUID CHANNEL_TYPE_UID_COLOR = new ChannelTypeUID(BINDING_ID, "color");
public static final ChannelTypeUID CHANNEL_TYPE_UID_DIMMER = new ChannelTypeUID(BINDING_ID, "dimmer");
public static final ChannelTypeUID CHANNEL_TYPE_UID_NUMBER = new ChannelTypeUID(BINDING_ID, "number");
public static final ChannelTypeUID CHANNEL_TYPE_UID_QUANTITY = new ChannelTypeUID(BINDING_ID, "quantity");
public static final ChannelTypeUID CHANNEL_TYPE_UID_STRING = new ChannelTypeUID(BINDING_ID, "string");
public static final ChannelTypeUID CHANNEL_TYPE_UID_SWITCH = new ChannelTypeUID(BINDING_ID, "switch");
public static final ChannelTypeUID CHANNEL_TYPE_UID_IR_CODE = new ChannelTypeUID(BINDING_ID, "ir-code");
@@ -12,12 +12,17 @@
*/
package org.openhab.binding.tuya.internal;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.SCHEMAS;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_PROJECT;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.THING_TYPE_TUYA_DEVICE;
import java.lang.reflect.Type;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -96,8 +101,18 @@ public class TuyaHandlerFactory extends BaseThingHandlerFactory {
if (THING_TYPE_PROJECT.equals(thingTypeUID)) {
return new ProjectHandler(thing, httpClient, storage, gson);
} else if (THING_TYPE_TUYA_DEVICE.equals(thingTypeUID)) {
return new TuyaDeviceHandler(thing, gson.fromJson(storage.get(thing.getUID().getId()), STORAGE_TYPE), gson,
dynamicCommandDescriptionProvider, eventLoopGroup, udpDiscoveryListener);
// stored schemas are usually more complete
Map<String, SchemaDp> schemaDps = SCHEMAS.get(thing.getConfiguration().get("productId"));
if (schemaDps == null) {
// fallback to retrieved schema
List<SchemaDp> listDps = Objects.requireNonNullElse(
gson.fromJson(storage.get(thing.getUID().getId()), STORAGE_TYPE), //
List.of());
schemaDps = listDps.stream()
.collect(Collectors.toMap(s -> s.code, s -> s, (e1, e2) -> e1, LinkedHashMap::new));
}
return new TuyaDeviceHandler(thing, schemaDps, gson, dynamicCommandDescriptionProvider, eventLoopGroup,
udpDiscoveryListener);
}
return null;
@@ -54,6 +54,9 @@ public class DeviceSchema {
public static class NumericRange {
public double min = Double.MIN_VALUE;
public double max = Double.MAX_VALUE;
public int scale = 0;
public int step = 1;
public String unit = "";
}
public boolean hasFunction(String fcn) {
@@ -16,10 +16,12 @@ import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYP
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_DIMMER;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_IR_CODE;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_NUMBER;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_QUANTITY;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_STRING;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.CHANNEL_TYPE_UID_SWITCH;
import static org.openhab.binding.tuya.internal.TuyaBindingConstants.SCHEMAS;
import static org.openhab.core.library.CoreItemFactory.NUMBER;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
@@ -34,6 +36,8 @@ import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.tuya.internal.config.ChannelConfiguration;
@@ -54,6 +58,7 @@ import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
@@ -72,6 +77,7 @@ import org.openhab.core.types.CommandOption;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.openhab.core.types.util.UnitUtils;
import org.openhab.core.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -100,7 +106,7 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
private final EventLoopGroup eventLoopGroup;
private DeviceConfiguration configuration = new DeviceConfiguration();
private @Nullable TuyaDevice tuyaDevice;
private final List<SchemaDp> schemaDps;
private final Map<String, SchemaDp> schemaDps;
private boolean oldColorMode = false;
private @Nullable ScheduledFuture<?> reconnectFuture;
@@ -117,15 +123,15 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
Duration.ofSeconds(10));
private final Map<String, State> channelStateCache = new HashMap<>();
public TuyaDeviceHandler(Thing thing, @Nullable List<SchemaDp> schemaDps, Gson gson,
public TuyaDeviceHandler(Thing thing, Map<String, SchemaDp> schemaDps, Gson gson,
BaseDynamicCommandDescriptionProvider dynamicCommandDescriptionProvider, EventLoopGroup eventLoopGroup,
UdpDiscoveryListener udpDiscoveryListener) {
super(thing);
this.schemaDps = schemaDps;
this.gson = gson;
this.udpDiscoveryListener = udpDiscoveryListener;
this.eventLoopGroup = eventLoopGroup;
this.dynamicCommandDescriptionProvider = dynamicCommandDescriptionProvider;
this.schemaDps = Objects.requireNonNullElse(schemaDps, List.of());
}
@Override
@@ -185,6 +191,30 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
return;
} else if (value instanceof String string && CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) {
updateState(channelId, new DecimalType(string));
return;
} else if ((Double.class.isAssignableFrom(value.getClass()) || value instanceof String)
&& CHANNEL_TYPE_UID_QUANTITY.equals(channelTypeUID)) {
BigDecimal d;
if (value instanceof String stringValue) {
d = new BigDecimal(stringValue);
} else {
d = new BigDecimal((double) value);
}
SchemaDp schemaDp = schemaDps.get(channelId);
if (schemaDp != null) {
d = d.movePointLeft(schemaDp.scale);
Unit<?> unit = schemaDp.parsedUnit;
if (unit != null) {
updateState(channelId, new QuantityType<>(d, unit));
return;
}
}
updateState(channelId, new DecimalType(d));
return;
} else if (Boolean.class.isAssignableFrom(value.getClass())
&& CHANNEL_TYPE_UID_SWITCH.equals(channelTypeUID)) {
updateState(channelId, OnOffType.from((boolean) value));
@@ -337,7 +367,28 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
}
} else if (CHANNEL_TYPE_UID_STRING.equals(channelTypeUID)) {
commandRequest.put(configuration.dp, command.toString());
} else if (CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) {
} else if (CHANNEL_TYPE_UID_QUANTITY.equals(channelTypeUID) || CHANNEL_TYPE_UID_NUMBER.equals(channelTypeUID)) {
if (command instanceof QuantityType quantityType) {
SchemaDp schemaDp = schemaDps.get(channelUID.getId());
if (schemaDp != null && !schemaDp.unit.isEmpty()) {
// If the item type for the channel is not dimensioned the unit is not usable and we
// assume whoever sent a quantity instead of a bare number knows what they are doing.
Channel channel = thing.getChannel(channelUID.getId());
if (channel != null && !NUMBER.equals(channel.getAcceptedItemType())) {
quantityType = quantityType.toUnit(schemaDp.unit);
}
}
if (quantityType != null) {
BigDecimal d = quantityType.toBigDecimal();
if (schemaDp != null) {
d = d.movePointRight(schemaDp.scale);
}
command = new DecimalType(d);
}
}
if (command instanceof DecimalType decimalType) {
commandRequest.put(configuration.dp,
configuration.sendAsString ? String.format("%d", decimalType.intValue())
@@ -428,21 +479,7 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
// check if we have channels and add them if available
if (thing.getChannels().isEmpty()) {
// stored schemas are usually more complete
Map<String, SchemaDp> schema = SCHEMAS.get(configuration.productId);
if (schema == null) {
if (!schemaDps.isEmpty()) {
// fallback to retrieved schema
schema = schemaDps.stream()
.collect(Collectors.toMap(s -> s.code, s -> s, (e1, e2) -> e1, LinkedHashMap::new));
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"No channels added and schema not found.");
return;
}
}
addChannels(schema);
addChannels();
}
thing.getChannels().forEach(this::configureChannel);
@@ -471,7 +508,7 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
configuration.localKey.getBytes(StandardCharsets.UTF_8), deviceInfo.ip, deviceInfo.protocolVersion);
}
private void addChannels(Map<String, SchemaDp> schema) {
private void addChannels() {
ThingBuilder thingBuilder = editThing();
ThingUID thingUID = thing.getUID();
ThingHandlerCallback callback = getCallback();
@@ -481,12 +518,13 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
return;
}
Map<String, Channel> channels = new LinkedHashMap<>(schema.entrySet().stream().map(e -> {
Map<String, Channel> channels = new LinkedHashMap<>(schemaDps.entrySet().stream().map(e -> {
String channelId = e.getKey();
SchemaDp schemaDp = e.getValue();
ChannelUID channelUID = new ChannelUID(thingUID, channelId);
Map<String, @Nullable Object> configuration = new HashMap<>();
String acceptedItemType = null;
Map<@Nullable String, @Nullable Object> configuration = new HashMap<>();
configuration.put("dp", schemaDp.id);
ChannelTypeUID channeltypeUID;
@@ -508,6 +546,28 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
channeltypeUID = CHANNEL_TYPE_UID_NUMBER;
configuration.put("min", schemaDp.min);
configuration.put("max", schemaDp.max);
if (schemaDp.scale > 0 || !schemaDp.unit.isEmpty()) {
channeltypeUID = CHANNEL_TYPE_UID_QUANTITY;
if (!schemaDp.unit.isEmpty()) {
Unit<?> unit = schemaDp.parsedUnit;
if (unit == null) {
unit = UnitUtils.parseUnit(schemaDp.unit);
schemaDp.parsedUnit = unit;
}
if (unit != null) {
String dimension = UnitUtils.getDimensionName(unit);
if (dimension != null) {
acceptedItemType = "Number:" + dimension;
} else {
logger.warn("{} has unit \"{}\" but openHAB doesn't know the dimension", channelId,
schemaDp.unit);
}
}
}
}
} else {
// e.g. type "raw", add empty channel
return Map.entry("", ChannelBuilder.create(channelUID).build());
@@ -525,8 +585,11 @@ public class TuyaDeviceHandler extends BaseThingHandler implements DeviceInfoSub
}
}
return Map.entry(channelId, callback.createChannelBuilder(channelUID, channeltypeUID)
.withLabel(schemaDp.label).withConfiguration(new Configuration(configuration)).build());
return Map.entry(channelId, callback.createChannelBuilder(channelUID, channeltypeUID) //
.withAcceptedItemType(acceptedItemType) //
.withLabel(schemaDp.label) //
.withConfiguration(new Configuration(configuration)) //
.build());
}).filter(c -> !c.getKey().isEmpty()).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)));
List<String> channelSuffixes = List.of("", "_1", "_2");
@@ -16,9 +16,12 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.tuya.internal.cloud.dto.DeviceSchema;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
@@ -40,9 +43,12 @@ public class SchemaDp {
public String code = "";
public String type = "";
public String label = "";
public String unit = "";
public @Nullable Double min;
public @Nullable Double max;
public Integer scale = 0;
public @Nullable List<String> range;
public @Nullable Unit<?> parsedUnit;
public static SchemaDp fromRemoteSchema(Gson gson, DeviceSchema.Description function) {
SchemaDp schemaDp = new SchemaDp();
@@ -59,8 +65,361 @@ public class SchemaDp {
gson.fromJson(function.values.replaceAll("\\\\", ""), DeviceSchema.NumericRange.class));
schemaDp.min = numericRange.min;
schemaDp.max = numericRange.max;
if (numericRange.scale == 1 && numericRange.min == 0 && numericRange.max == 1 && numericRange.step == 1) {
LoggerFactory.getLogger(SchemaDp.class).warn("Ignoring scale=1 when min=0, max=1, step=1 for {}",
schemaDp.code);
} else {
schemaDp.scale = numericRange.scale;
}
schemaDp.unit = normalizeUnit(schemaDp.code, numericRange);
}
return schemaDp;
}
// This is very like the normalizeUnit in convert.js. We really only need to deal with _likely_ garbage
// in _remote_ schemas here because the local schemas are handled by convert.js however assuming the
// same mistakes aren't going to be repeated ad infinitum is probably unwise.
private static String normalizeUnit(String code, DeviceSchema.NumericRange properties) {
// Identifiers may be suffixed with an instance number if it's repeated.
code = code.replaceFirst("_?\\d+$", "");
String unit = properties.unit //
.replaceAll("[  ­.·]+", "") // Remove all forms of space and dots.
.replaceAll("μ", "µ") // Unicode has both the Greek letter and a micro sign - we prefer the latter.
.replaceAll("[˚º]", "°") // Degree sign rather than ring above or masculine ordinal indicator.
.replaceAll("", "") // Avoid the single character m squared.
.replaceAll("(?<=[^-\\d])2((?!\\d)|$)", "²") // Superscript 2s.
.replaceAll("(?<=[^-\\d])3((?!\\d)|$)", "³") // Superscript 3s.
.replaceAll("", "°C") // Prefer two characters for degree Celsius.
.replaceAll("°c", "°C") // ... and capitalized.
.replaceAll("", "°F") // Prefer two characters for degree Fahrenheit.
.replaceAll("°f", "°F") // ... and capitalized.
;
// If we are given a unit try and translate it into something comprehensible.
switch (unit) {
case "0":
case "1":
case "Number":
case "step":
case "times":
case "x":
case "X":
case "PF": // Used as an abbreviation for power factor.
case "pf":
case "ppt": // Probably Parts Per Thousand but not available as a unit in openHAB.
case "SG": // Specific Gravity but not available as a unit in openHAB.
case "sg":
case "": // share/part/copy/portion
case "": // number
case "": // part/segment
case "": // none
case "": // step/pace/footsteps
case "": // Yuan
return "";
case "%RH":
case "RH%":
case "":
case "%LEL":
case "LEL":
case "百分比(%":
return "%";
case "°C/°F":
case "°C°F":
case "°C/F":
case "摄氏度或华氏度":
// FIXME: some devices seem to be switchable between °C and °F. We may
// need code in the handler to deal with these.
// return "°C°F"
return "";
case "01s":
switch (properties.scale) {
case 0:
properties.scale = 1;
case 1:
return "s";
}
break;
case "Amp":
return "A";
case "mAh":
switch (code) {
case "cur_voltage": // At least one product has a cur_voltage with a unit of "mAh"!
return "V";
}
return unit;
case "C": // FIXME: could be Coulombs...?
case "c":
case "摄氏度":
return "°C";
case "CM":
case "厘米":
return "cm";
case "day":
case "":
return "d";
case "F": // FIXME: could be Farads...?
case "华氏度":
return "°F";
case "mg/L":
return "mg/l";
case "µg/m³":
case "ug/m³":
case "um/m³": // Seen on some particulate matter standard functions.
return "µg/m³";
case "HZ":
return "Hz";
case "hour":
case "H":
case "Hour":
case "小时":
case "小时(暂定)":
return "h";
case "hPa/mb":
return "hPa";
case "inch":
return "in";
case "Kcal":
return "kcal";
case "KG":
case "Kg":
return "kg";
case "Klux":
return "klx";
case "KM":
return "km";
case "KM/h":
return "km/h";
case "Kpa":
return "kPa";
case "kVar":
case "Kvar":
case "KVar":
return "kvar";
case "kVarh":
case "Kvarh":
case "KVarh":
return "kvarh";
case "kw":
case "Kw":
case "KW":
return "kW";
case "kwh":
case "Kwh":
case "KwH":
case "KWh":
case "KWH":
case "kW*H":
case "kW*h":
case "KW*H":
case "k-Wh":
return "kWh";
case "L":
case "":
case "升(L":
return "l";
case "L/min":
case "升/分钟":
return "l/min";
case "lux":
case "Lux":
case "LUX":
return "lx";
case "M":
case "":
return "m";
case "mL":
return "ml";
case "":
return "";
case "立方米":
return "";
case "Mhz":
return "MHz";
case "Min":
case "MIN":
case "mins":
case "minute":
case "Minute":
case "minutes":
case "mintues":
case "分钟":
case "分钟m":
return "min";
case "Mpa":
return "MPa";
case "MPH":
return "mph";
case "ph":
return "pH";
case "PPM":
return "ppm";
case "PSI":
return "psi";
case "RPM":
case "r/s":
return "rpm";
case "s":
case "S": // Siemens or seconds?
if (code.startsWith("ec")) { // ec = Electrical Conductance
return "S";
}
return "s";
case "sec":
case "Sec":
case "second":
case "Seconds":
case "seconds":
case "SECOND":
case "":
return "s";
case "µs":
case "µS":
case "uS":
case "us": // Siemens or seconds?
if (code.startsWith("ec")) { // ec = Electrical Conductance
return "µS";
}
return "µs";
case "mS/cm":
case "ms/cm": // Siemens or seconds? ms/cm seems unlikely...
return "mS/cm";
case "uS/cm":
case "us/cm": // Siemens or seconds? µs/cm seems unlikely...
return "µS/cm";
case "v":
case "VAC":
case "VDC":
return "V";
case "w":
case "Watt":
return "W";
case "wh":
return "Wh";
case "欧姆":
return "Ω";
case "": // degree/time/counter for ...
switch (code) {
case "KCWD":
case "JLWD":
case "SSWD": // window open/comfort/econ temperatures with min=5.0 max=30.0
return "°C";
}
break;
case "": // point/minute/fraction/part/tenth
switch (code) {
case "alarm_sound_duration":
case "Clean_time":
case "clean_time":
return "min";
}
break;
case "": // files/pigeonhole
switch (code) {
case "fan_speed":
return "rpm";
case "sensitivity":
case "move_sensitivity":
case "presence_sensitivity":
return "";
}
break;
}
// Not all standard functions have a default unit set and not all developers set one.
// Not all custom functions are... well... sane.
switch (code) {
case "add_ele":
if (properties.scale == 3) {
return "kWh";
} else if (properties.scale == 0) {
return "Wh";
}
break;
case "countdown":
if (properties.min == 0 && properties.max == 86400) {
return "s";
}
break;
case "time_zone":
// A couple of products say unit="z" but have a range of 0-23. Mind you, no negative
// offset and scale=0, step=1 so it has restricted utility anyway.
if (properties.min == 0 && properties.max == 23) {
return "h";
} else {
switch (unit) {
case "z":
return "";
}
}
break;
case "wind_dir360":
if (properties.min == 0 && (properties.max == 360 || properties.max == 361)) {
return "°";
}
break;
}
// Use what it said. The unit will get checked for usability when the Channels are added to Things.
return unit;
}
}
@@ -197,6 +197,33 @@
</config-description>
</channel-type>
<channel-type id="quantity">
<item-type>Number</item-type>
<label>Number</label>
<category>Number</category>
<state pattern="%.3f %unit%"></state>
<config-description>
<parameter name="dp" type="integer" required="true">
<label>DP</label>
</parameter>
<parameter name="min" type="integer">
<label>Minimum</label>
</parameter>
<parameter name="max" type="integer">
<label>Maximum</label>
</parameter>
<parameter name="sendAsString" type="boolean">
<label>Send As String</label>
<description>Send the value as string instead of number.</description>
<default>false</default>
<advanced>true</advanced>
</parameter>
</config-description>
</channel-type>
<channel-type id="string">
<item-type>String</item-type>
<label>String</label>
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,401 @@
const http = require('https');
const fs = require('fs');
function normalizeUnit(productKey, orig_code, properties, orig_unit) {
// Identifiers may be suffixed with an instance number if it's repeated.
let code = orig_code.replaceAll(/_?\d+$/g, "");
let unit = orig_unit
.replaceAll(/[  ­.·]+/g, "") // Remove all forms of space and dots.
.replaceAll(/μ/g, "µ") // Unicode has both the Greek letter and a micro sign - we prefer the latter.
.replaceAll(/[˚º]/g, "°") // Degree sign rather than ring above or masculine ordinal indicator.
.replaceAll(/㎡/g, "m²") // Avoid the single character m squared.
.replaceAll(/(?<=[^-\d])2((?!\d)|$)/g, "²") // Superscript 2s
.replaceAll(/(?<=[^-\d])3((?!\d)|$)/g, "³") // Superscript 3s
.replaceAll(/℃/g, "°C") // Prefer two characters for degree Celsius.
.replaceAll(/°c/g, "°C") // ... and capitalized.
.replaceAll(/℉/g, "°F") // Prefer two characters for degree Fahrenheit.
.replaceAll(/°f/g, "°F") // ... and capitalized.
;
// If we are given a unit try and translate it into something comprehensible.
switch (unit) {
// Standard units accepted as-is. Anything not explicitly matched here, or fixed
// up elsewhere, will cause an "Unsure ..." message to be logged and the unit will
// be omitted from the curated schema.
// Note that some standard units used are not listed here because they have special
// handling further down.
case "°": case "%": case "°C": case "°F": case "Ω":
case "A": case "mA":
case "Ah":
case "bar": case "mbar":
case "cal": case "kcal":
case "d": case "h": case "min": case "ms": // "s" has special handling below
case "dBm":
case "g": case "kg": case "mg": case "mg/m³":
case "gal": case "gal/h": case "gal/min":
case "Hz": case "kHz": case "MHz":
case "in":
case "l": case "ml": case "l/min": case "ml/day":
case "lx":
case "m": case "m²": case "m³": case "m/s": case "cm": case "km": case "km/h": case "mm":
case "mph":
case "Nm":
case "Pa": case "hPa": case "kPa":
case "pH":
case "ppm":
case "psi":
case "rpm":
case "µS/cm":
case "V": case "mV":
case "var": case "kvar": case "kvarh":
case "W": case "Wh": case "kW": case "kWh":
return unit;
case "0":
case "1":
case "Number":
case "step":
case "times":
case "x":
case "X":
case "PF": // Used as an abbreviation for power factor.
case "pf":
case "ppt": // Probably Parts Per Thousand but not available as a unit in openHAB.
case "SG": // Specific Gravity but not available as a unit in openHAB.
case "sg":
case "份": // share/part/copy/portion
case "次": // number
case "段": // part/segment
case "无": // none
case "步": // step/pace/footsteps
case "元": // Yuan
return "";
case "%RH":
case "RH%":
case "":
case "%LEL":
case "LEL":
case "百分比(%":
return "%";
case "°C/°F":
case "°C°F":
case "°C/F":
case "摄氏度或华氏度":
// FIXME: some devices seem to be switchable between °C and °F. We may
// need code in the handler to deal with these.
// return "°C°F"
return "";
case "01s":
switch (properties.scale) {
case 0:
properties.scale = 1;
case 1:
return "s";
}
break;
case "Amp":
return "A";
case "mAh":
switch (code) {
case "cur_voltage": // At least one product has a cur_voltage with a unit of "mAh"!
return "V";
}
return unit;
case "C": // FIXME: could be Coulombs...?
case "c":
case "摄氏度":
return "°C";
case "CM":
case "厘米":
return "cm";
case "day":
case "天":
return "d";
case "F": // FIXME: could be Farads...?
case "华氏度":
return "°F";
case "mg/L":
return "mg/l";
case "µg/m³":
case "ug/m³":
case "um/m³": // Seen on some particulate matter standard functions.
return "µg/m³";
case "HZ":
return "Hz";
case "hour":
case "H":
case "Hour":
case "小时":
case "小时(暂定)":
return "h";
case "hPa/mb":
return "hPa";
case "inch":
return "in";
case "Kcal":
return "kcal";
case "KG":
case "Kg":
return "kg";
case "Klux":
return "klx";
case "KM":
return "km";
case "KM/h":
return "km/h";
case "Kpa":
return "kPa";
case "kVar":
case "Kvar":
case "KVar":
return "kvar";
case "kVarh":
case "Kvarh":
case "KVarh":
return "kvarh";
case "kw":
case "Kw":
case "KW":
return "kW";
case "kwh":
case "Kwh":
case "KwH":
case "KWh":
case "KWH":
case "kW*H":
case "kW*h":
case "KW*H":
case "k-Wh":
return "kWh";
case "L":
case "升":
case "升(L":
return "l";
case "L/min":
case "升/分钟":
return "l/min";
case "lux":
case "Lux":
case "LUX":
return "lx";
case "M":
case "米":
return "m";
case "mL":
return "ml";
case "M²":
return "m²";
case "立方米":
return "m³";
case "Mhz":
return "MHz";
case "Min":
case "MIN":
case "mins":
case "minute":
case "Minute":
case "minutes":
case "mintues":
case "分钟":
case "分钟m":
return "min";
case "Mpa":
return "MPa";
case "MPH":
return "mph";
case "ph":
return "pH";
case "PPM":
return "ppm";
case "PSI":
return "psi";
case "RPM":
case "r/s":
return "rpm";
case "s":
case "S": // Siemens or seconds?
if (code.startsWith("ec")) { // ec = Electrical Conductance
return "S";
}
return "s";
case "sec":
case "Sec":
case "second":
case "Seconds":
case "seconds":
case "SECOND":
case "秒":
return "s";
case "µs":
case "µS":
case "uS":
case "us": // Siemens or seconds?
if (code.startsWith("ec")) { // ec = Electrical Conductance
return "µS";
}
return "µs";
case "mS/cm":
case "ms/cm": // Siemens or seconds? ms/cm seems unlikely...
return "mS/cm";
case "uS/cm":
case "us/cm": // Siemens or seconds? µs/cm seems unlikely...
return "µS/cm";
case "v":
case "VAC":
case "VDC":
return "V";
case "w":
case "Watt":
return "W";
case "wh":
return "Wh";
case "欧姆":
return "Ω";
case "度": // degree/time/counter for ...
switch (code) {
case "KCWD":
case "JLWD":
case "SSWD": // window open/comfort/econ temperatures with min=5.0 max=30.0
return "°C";
}
break;
case "分": // point/minute/fraction/part/tenth
switch (code) {
case "alarm_sound_duration":
case "Clean_time":
case "clean_time":
return "min";
}
break;
case "档": // files/pigeonhole
switch (code) {
case "fan_speed":
return "rpm";
case "sensitivity":
case "move_sensitivity":
case "presence_sensitivity":
return "";
}
break;
}
// Not all standard functions have a default unit set and not all developers set one.
// And not all units set are... well... sane.
switch (code) {
case "a_batterypencent":
return "%";
case "a_BatteryVoltage":
return "V";
case "add_ele":
if (properties.scale == 3) {
return "kWh";
} else if (properties.scale == 0) {
return "Wh";
}
break;
case "countdown":
if (properties.min == 0 && properties.max == 86400) {
return "s";
}
break;
case "time_zone":
// A couple of products say unit="z" but have a range of 0-23. Mind you, no negative
// offset and scale=0, step=1 so it has restricted utility anyway.
if (properties.min == 0 && properties.max == 23) {
return "h";
} else {
switch (unit) {
case "z":
return "";
}
}
break;
case "wind_dir360":
if (properties.min == 0 && (properties.max == 360 || properties.max == 361)) {
return "°";
}
break;
}
if (unit !== "") {
console.log("Unsure about unit " + orig_unit + " (mapped \"" + unit +"\") for " + orig_code + " in " + productKey
+ " min=" + properties.min
+ " max=" + properties.max
+ " scale=" + properties.scale
+ " step=" + properties.step
);
} else if (false) {
console.log("No unit for " + orig_code + " in " + productKey
+ " min=" + properties.min
+ " max=" + properties.max
+ " scale=" + properties.scale
+ " step=" + properties.step
);
}
return "";
}
const prevSchemas = require('../../../src/main/resources/schema.json');
const schemaJson = fs.createWriteStream("../../../target/in-schema.json");
http.get("https://raw.githubusercontent.com/Apollon77/ioBroker.tuya/master/lib/schema.json", function(response) {
response.setEncoding('utf8');
@@ -32,6 +427,9 @@ http.get("https://raw.githubusercontent.com/Apollon77/ioBroker.tuya/master/lib/s
let convertedSchemas = {};
for (productKey in knownSchemas) {
if (process.argv[2] == 'existing' && typeof prevSchemas[productKey] == 'undefined') {
continue;
}
try {
let schema = JSON.parse(knownSchemas[productKey].schema);
let convertedSchema = {};
@@ -44,10 +442,25 @@ http.get("https://raw.githubusercontent.com/Apollon77/ioBroker.tuya/master/lib/s
convertedEntry = {id: entry.id, type: entry.property.type};
if (convertedEntry.type === 'enum') {
convertedEntry['range'] = entry.property.range;
}
if (convertedEntry.type === 'value' && entry.property.min !== null && entry.property.max !== null) {
convertedEntry['min'] = entry.property.min;
convertedEntry['max'] = entry.property.max;
} else if (convertedEntry.type === 'value') {
if (entry.property.min !== null && entry.property.max !== null) {
convertedEntry['min'] = entry.property.min;
convertedEntry['max'] = entry.property.max;
}
if (typeof entry.property.scale !== 'undefined') {
if (entry.property.scale == 1 && entry.property.min == 0 && entry.property.max == 1 && (typeof entry.property.step == 'undefined' || entry.property.step >= 1)) {
console.log('Ignoring scale=1 when min=0, max=1, step=' + (typeof entry.property.step == 'undefined' ? 1 : entry.property.step) + ' for ' + productKey + " " + entry.code);
} else if (entry.property.scale != 0) {
convertedEntry['scale'] = entry.property.scale;
}
}
if (typeof entry.property.unit === 'undefined') {
entry.property.unit = "";
}
let unit = normalizeUnit(productKey, entry.code, entry.property, entry.property.unit);
if (unit !== "") {
convertedEntry['unit'] = unit;
}
}
}
convertedSchema[entry.code] = convertedEntry;
@@ -60,7 +473,16 @@ http.get("https://raw.githubusercontent.com/Apollon77/ioBroker.tuya/master/lib/s
}
}
fs.writeFile('../resources/schema.json', JSON.stringify(convertedSchemas, null, '\t'), (err) => {
const replacer = (key, value) =>
value instanceof Object && !(value instanceof Array) ?
Object.keys(value)
.sort()
.reduce((sorted, key) => {
sorted[key] = value[key];
return sorted;
}, {}) : value;
fs.writeFile('../resources/schema.json', JSON.stringify(convertedSchemas, replacer, '\t'), (err) => {
if (err) throw err;
});
});