[miio] Automatic create experimental support for (unsupported) miot devices (#11149)

Signed-off-by: Marcel Verpaalen <marcel@verpaalen.com>
This commit is contained in:
Marcel 2021-09-19 22:01:22 +02:00 committed by GitHub
parent b0cbefdf7a
commit 2fb86d7138
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1283 additions and 17 deletions

View File

@ -65,6 +65,7 @@ public final class MiIoBindingConstants {
public static final String CHANNEL_VACUUM = "actions#vacuum";
public static final String CHANNEL_FAN_CONTROL = "actions#fan";
public static final String CHANNEL_TESTCOMMANDS = "actions#testcommands";
public static final String CHANNEL_TESTMIOT = "actions#testmiot";
public static final String CHANNEL_POWER = "actions#power";
public static final String CHANNEL_SSID = "network#ssid";

View File

@ -29,6 +29,7 @@ import org.openhab.binding.miio.internal.handler.MiIoGenericHandler;
import org.openhab.binding.miio.internal.handler.MiIoUnsupportedHandler;
import org.openhab.binding.miio.internal.handler.MiIoVacuumHandler;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@ -54,6 +55,7 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory {
private static final String THING_HANDLER_THREADPOOL_NAME = "thingHandler";
protected final ScheduledExecutorService scheduler = ThreadPoolManager
.getScheduledPool(THING_HANDLER_THREADPOOL_NAME);
private final HttpClientFactory httpClientFactory;
private MiIoDatabaseWatchService miIoDatabaseWatchService;
private CloudConnector cloudConnector;
private ChannelTypeRegistry channelTypeRegistry;
@ -62,9 +64,11 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(MiIoHandlerFactory.class);
@Activate
public MiIoHandlerFactory(@Reference ChannelTypeRegistry channelTypeRegistry,
public MiIoHandlerFactory(@Reference HttpClientFactory httpClientFactory,
@Reference ChannelTypeRegistry channelTypeRegistry,
@Reference MiIoDatabaseWatchService miIoDatabaseWatchService, @Reference CloudConnector cloudConnector,
@Reference BasicChannelTypeProvider basicChannelTypeProvider, Map<String, Object> properties) {
this.httpClientFactory = httpClientFactory;
this.miIoDatabaseWatchService = miIoDatabaseWatchService;
this.channelTypeRegistry = channelTypeRegistry;
this.basicChannelTypeProvider = basicChannelTypeProvider;
@ -113,6 +117,7 @@ public class MiIoHandlerFactory extends BaseThingHandlerFactory {
if (thingTypeUID.equals(THING_TYPE_VACUUM)) {
return new MiIoVacuumHandler(thing, miIoDatabaseWatchService, cloudConnector, channelTypeRegistry);
}
return new MiIoUnsupportedHandler(thing, miIoDatabaseWatchService, cloudConnector);
return new MiIoUnsupportedHandler(thing, miIoDatabaseWatchService, cloudConnector,
httpClientFactory.getCommonHttpClient());
}
}

View File

@ -48,7 +48,7 @@ public enum MiIoQuantiyTypes {
MILLI_AMPERE(MILLI(Units.AMPERE), "mA"),
VOLT(Units.VOLT),
MILLI_VOLT(MILLI(Units.VOLT), "mV"),
WATT(Units.WATT),
WATT(Units.WATT, "W", "w"),
LITRE(Units.LITRE, "liter"),
LUX(Units.LUX),
RADIANS(Units.RADIAN, "radians"),

View File

@ -59,7 +59,7 @@ public class StateDescriptionDTO {
return minimum;
}
public void setMinimum(BigDecimal minimum) {
public void setMinimum(@Nullable BigDecimal minimum) {
this.minimum = minimum;
}
@ -68,7 +68,7 @@ public class StateDescriptionDTO {
return maximum;
}
public void setMaximum(BigDecimal maximum) {
public void setMaximum(@Nullable BigDecimal maximum) {
this.maximum = maximum;
}
@ -77,7 +77,7 @@ public class StateDescriptionDTO {
return step;
}
public void setStep(BigDecimal step) {
public void setStep(@Nullable BigDecimal step) {
this.step = step;
}
@ -86,7 +86,7 @@ public class StateDescriptionDTO {
return pattern;
}
public void setPattern(String pattern) {
public void setPattern(@Nullable String pattern) {
this.pattern = pattern;
}
@ -95,7 +95,7 @@ public class StateDescriptionDTO {
return readOnly;
}
public void setReadOnly(Boolean readOnly) {
public void setReadOnly(@Nullable Boolean readOnly) {
this.readOnly = readOnly;
}
@ -104,7 +104,7 @@ public class StateDescriptionDTO {
return options;
}
public void setOptions(List<OptionsValueListDTO> options) {
public void setOptions(@Nullable List<OptionsValueListDTO> options) {
this.options = options;
}
}

View File

@ -31,6 +31,7 @@ import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
import org.openhab.binding.miio.internal.MiIoCommand;
import org.openhab.binding.miio.internal.MiIoDevices;
@ -41,6 +42,7 @@ import org.openhab.binding.miio.internal.basic.MiIoBasicChannel;
import org.openhab.binding.miio.internal.basic.MiIoBasicDevice;
import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
import org.openhab.binding.miio.internal.cloud.CloudConnector;
import org.openhab.binding.miio.internal.miot.MiotParser;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.ChannelUID;
@ -67,6 +69,7 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
private static final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
private static final Gson GSONP = new GsonBuilder().setPrettyPrinting().create();
private final HttpClient httpClient;
private final Logger logger = LoggerFactory.getLogger(MiIoUnsupportedHandler.class);
private final MiIoBindingConfiguration conf = getConfigAs(MiIoBindingConfiguration.class);
@ -84,8 +87,9 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
});
public MiIoUnsupportedHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
CloudConnector cloudConnector) {
CloudConnector cloudConnector, HttpClient httpClientFactory) {
super(thing, miIoDatabaseWatchService, cloudConnector);
this.httpClient = httpClientFactory;
}
@Override
@ -112,6 +116,9 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
if (channelUID.getId().equals(CHANNEL_TESTCOMMANDS)) {
executeExperimentalCommands();
}
if (channelUID.getId().equals(CHANNEL_TESTMIOT)) {
executeCreateMiotTestFile();
}
}
@Override
@ -189,6 +196,59 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
logger.info("{}", sb.toString());
}
private void executeCreateMiotTestFile() {
sb = new StringBuilder();
try {
MiotParser miotParser;
miotParser = MiotParser.parse(model, httpClient);
logger.info("urn: {}", miotParser.getUrn());
logger.info("{}", miotParser.getUrnData());
MiIoBasicDevice device = miotParser.getDevice();
if (device == null) {
logger.debug("Error while creating experimental miot db file for {}.", conf.model);
return;
}
sendCommand(MiIoCommand.MIIO_INFO);
logger.info("Start experimental creation of database file based on MIOT spec for device '{}'. ",
miDevice.toString());
sb.append("Info for ");
sb.append(conf.model);
sb.append("\r\n");
sb.append("Database file created:");
sb.append(writeDevice(device, true));
sb.append("\r\n");
sb.append(MiotParser.toJson(device));
sb.append("\r\n");
sb.append("Testing Properties:\r\n");
int lastCommand = -1;
for (MiIoBasicChannel ch : device.getDevice().getChannels()) {
if (ch.isMiOt() && ch.getRefresh()) {
JsonObject json = new JsonObject();
json.addProperty("did", ch.getProperty());
json.addProperty("siid", ch.getSiid());
json.addProperty("piid", ch.getPiid());
String cmd = ch.getChannelCustomRefreshCommand().isBlank()
? ("get_properties[" + json.toString() + "]")
: ch.getChannelCustomRefreshCommand();
sb.append(ch.getChannel());
sb.append(" -> ");
sb.append(cmd);
sb.append(" -> ");
lastCommand = sendCommand(cmd);
sb.append(lastCommand);
sb.append(", \r\n");
testChannelList.put(lastCommand, ch);
}
}
this.lastCommand = lastCommand;
sb.append("\r\n");
logger.info("{}", sb.toString());
} catch (Exception e) {
logger.debug("Error while creating experimental miot db file for {}", conf.model);
logger.info("{}", sb.toString());
}
}
private LinkedHashMap<String, MiIoBasicChannel> collectProperties(@Nullable String model) {
LinkedHashMap<String, MiIoBasicChannel> testChannelsList = new LinkedHashMap<>();
LinkedHashSet<MiIoDevices> testDeviceList = new LinkedHashSet<>();
@ -255,7 +315,14 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
sb.append("===================================\r\n");
sb.append("Device Info: ");
sb.append(info);
sb.append("\r\n");
sb.append(supportedChannelList.size());
sb.append(" channels with responses.\r\n");
int miotChannels = 0;
for (MiIoBasicChannel ch : supportedChannelList.keySet()) {
if (ch.isMiOt()) {
miotChannels++;
}
sb.append("Property: ");
sb.append(Utils.minLengthString(ch.getProperty(), 15));
sb.append(" Friendly Name: ");
@ -264,15 +331,17 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
sb.append(supportedChannelList.get(ch));
sb.append("\r\n");
}
if (!supportedChannelList.isEmpty()) {
boolean isMiot = miotChannels > supportedChannelList.size() / 2;
if (!supportedChannelList.isEmpty() && !isMiot) {
MiIoBasicDevice mbd = createBasicDeviceDb(model, new ArrayList<>(supportedChannelList.keySet()));
sb.append("Created experimental database for your device:\r\n");
sb.append(GSONP.toJson(mbd));
sb.append("\r\nDatabase file saved to: ");
sb.append(writeDevice(mbd));
sb.append(writeDevice(mbd, false));
isIdentified = false;
} else {
sb.append("No supported channels found.\r\n");
sb.append(isMiot ? "Miot file already created. Manually remove non-functional channels.\r\n"
: "No supported channels found.\r\n");
}
sb.append("\r\nDevice testing file saved to: ");
sb.append(writeLog());
@ -303,14 +372,14 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
return device;
}
private String writeDevice(MiIoBasicDevice device) {
private String writeDevice(MiIoBasicDevice device, boolean miot) {
File folder = new File(BINDING_DATABASE_PATH);
if (!folder.exists()) {
folder.mkdirs();
}
File dataFile = new File(folder, model + "-experimental.json");
File dataFile = new File(folder, model + (miot ? "-miot" : "") + "-experimental.json");
try (FileWriter writer = new FileWriter(dataFile)) {
writer.write(GSONP.toJson(device));
writer.write(miot ? MiotParser.toJson(device) : GSONP.toJson(device));
logger.debug("Experimental database file created: {}", dataFile.getAbsolutePath());
return dataFile.getAbsolutePath().toString();
} catch (IOException e) {

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.List;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Mapping properties from json for miot device info
*
* @author Marcel Verpaalen - Initial contribution
*/
public class ActionDTO {
@SerializedName("iid")
@Expose
public Integer iid;
@SerializedName("type")
@Expose
public String type;
@SerializedName("description")
@Expose
public String description;
@SerializedName("in")
@Expose
public List<Object> in = null;
@SerializedName("out")
@Expose
public List<Object> out = null;
}

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.List;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Mapping properties from json for miot device info
*
* @author Marcel Verpaalen - Initial contribution
*/
public class EventDTO {
@SerializedName("iid")
@Expose
public Integer iid;
@SerializedName("type")
@Expose
public String type;
@SerializedName("description")
@Expose
public String description;
@SerializedName("arguments")
@Expose
public List<Integer> arguments = null;
}

View File

@ -0,0 +1,93 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Enum of the unitTypes used in the miio protocol
* Used to find the right {@link javax.measure.unitType} given the string of the unitType
*
* @author Marcel Verpaalen - Initial contribution
*/
@NonNullByDefault
public enum MiIoQuantiyTypesConversion {
ANGLE("Angle", "arcdegrees", "radians"),
DENSITY("Density", "mg/m3"),
DIMENSIONLESS("Dimensionless", "percent", "percentage", "ppm"),
ELECTRIC_POTENTIAL("ElectricPotential", "volt"),
POWER("Power", "watt", "w"),
CURRENT("ElectricCurrent", "ampere", "mA"),
ILLUMINANCE("Illuminance", "lux"),
PRESSURE("Pressure", "pascal"),
TEMPERATURE("Temperature", "c", "celcius", "celsius", "f", "farenheith", "kelvin", "K"),
TIME("Time", "seconds", "minutes", "minute", "hour", "hours", "days", "Months"),
VOLUME("Volume", "litre", "liter", "m3");
/*
* available options according to miot spec:
* percentage
* Celsius degrees Celsius
* seconds
* minutes
* hours
* days
* kelvin temperature scale
* pascal Pascal (atmospheric pressure unit)
* arcdegrees radians (angle units)
* rgb RGB (color)
* watt (power)
* litre
* ppm ppm concentration
* lux Lux (illuminance)
* mg/m3 milligrams per cubic meter
*/
private final String unitType;
private final String[] aliasses;
private static Map<String, String> aliasMap() {
Map<String, String> aliassesMap = new HashMap<>();
for (MiIoQuantiyTypesConversion miIoQuantiyType : values()) {
for (String alias : miIoQuantiyType.getAliasses()) {
aliassesMap.put(alias.toLowerCase(), miIoQuantiyType.getunitType());
}
}
return aliassesMap;
}
private MiIoQuantiyTypesConversion(String unitType, String... aliasses) {
this.unitType = unitType;
this.aliasses = aliasses;
}
public String getunitType() {
return unitType;
}
public String[] getAliasses() {
return aliasses;
}
public static @Nullable String getType(@Nullable String unitTypeName) {
if (unitTypeName != null) {
return aliasMap().get(unitTypeName.toLowerCase());
}
return null;
}
}

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.List;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Mapping properties from json for miot device info
*
* @author Marcel Verpaalen - Initial contribution
*/
public class MiotDeviceDataDTO {
@SerializedName("type")
@Expose
public String type;
@SerializedName("description")
@Expose
public String description;
@SerializedName("services")
@Expose
public List<ServiceDTO> services = null;
}

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Will be thrown for cloud errors
*
* @author Marcel Verpaalen - Initial contribution
*/
@NonNullByDefault
public class MiotParseException extends Exception {
/**
* required variable to avoid IncorrectMultilineIndexException warning
*/
private static final long serialVersionUID = -1280858607995252322L;
public MiotParseException() {
super();
}
public MiotParseException(@Nullable String message) {
super(message);
}
public MiotParseException(@Nullable String message, @Nullable Exception e) {
super(message, e);
}
}

View File

@ -0,0 +1,454 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.openhab.binding.miio.internal.MiIoCommand;
import org.openhab.binding.miio.internal.basic.CommandParameterType;
import org.openhab.binding.miio.internal.basic.DeviceMapping;
import org.openhab.binding.miio.internal.basic.MiIoBasicChannel;
import org.openhab.binding.miio.internal.basic.MiIoBasicDevice;
import org.openhab.binding.miio.internal.basic.MiIoDeviceAction;
import org.openhab.binding.miio.internal.basic.MiIoDeviceActionCondition;
import org.openhab.binding.miio.internal.basic.OptionsValueListDTO;
import org.openhab.binding.miio.internal.basic.StateDescriptionDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
/**
* Support creation of the miot db files
* based on the the online miot spec files
*
*
* @author Marcel Verpaalen - Initial contribution
*/
@NonNullByDefault
public class MiotParser {
private final Logger logger = LoggerFactory.getLogger(MiotParser.class);
private static final String BASEURL = "http://miot-spec.org/miot-spec-v2/";
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final boolean SKIP_SIID_1 = true;
private String model;
private @Nullable String urn;
private @Nullable JsonElement urnData;
private @Nullable MiIoBasicDevice device;
public MiotParser(String model) {
this.model = model;
}
public static MiotParser parse(String model, HttpClient httpClient) throws MiotParseException {
MiotParser miotParser = new MiotParser(model);
try {
String urn = miotParser.getURN(model, httpClient);
if (urn == null) {
throw new MiotParseException("Device not found in in miot specs : " + model);
}
JsonElement urnData = miotParser.getUrnData(urn, httpClient);
miotParser.getDevice(urnData);
return miotParser;
} catch (Exception e) {
throw new MiotParseException("Error parsing miot data: " + e.getMessage(), e);
}
}
/**
* Outputs the device json file touched up so the format matches the regular OH standard formatting
*
* @param device
* @return
*/
static public String toJson(MiIoBasicDevice device) {
String usersJson = GSON.toJson(device);
usersJson = usersJson.replace(".0,\n", ",\n");
usersJson = usersJson.replace("\n", "\r\n").replace(" ", "\t");
return usersJson;
}
public void writeDevice(String path, MiIoBasicDevice device) {
try (PrintWriter out = new PrintWriter(path)) {
out.println(toJson(device));
logger.info("Database file created:{}", path);
} catch (FileNotFoundException e) {
logger.info("Error writing file: {}", e.getMessage());
}
}
public MiIoBasicDevice getDevice(JsonElement urnData) throws MiotParseException {
Set<String> unknownUnits = new HashSet<>();
Map<ActionDTO, ServiceDTO> deviceActions = new LinkedHashMap<>();
StringBuilder channelConfigText = new StringBuilder("Suggested additional channelType \r\n");
StringBuilder actionText = new StringBuilder("Manual actions for execution\r\n");
MiIoBasicDevice device = new MiIoBasicDevice();
DeviceMapping deviceMapping = new DeviceMapping();
MiotDeviceDataDTO miotDevice = GSON.fromJson(urnData, MiotDeviceDataDTO.class);
if (miotDevice == null) {
throw new MiotParseException("Error parsing miot data: null");
}
List<MiIoBasicChannel> miIoBasicChannels = new ArrayList<>();
deviceMapping.setPropertyMethod(MiIoCommand.GET_PROPERTIES.getCommand());
deviceMapping.setMaxProperties(1);
deviceMapping.setExperimental(true);
deviceMapping.setId(Arrays.asList(new String[] { model }));
Set<String> propCheck = new HashSet<>();
for (ServiceDTO service : miotDevice.services) {
String serviceId = service.type.substring(service.type.indexOf("service:")).split(":")[1];
logger.info("SID: {}, description: {}, identifier: {}", service.siid, service.description, serviceId);
if (service.properties != null) {
for (PropertyDTO property : service.properties) {
String propertyId = property.type.substring(property.type.indexOf("property:")).split(":")[1];
logger.info("siid: {}, description: {}, piid: {}, description: {}, identifier: {}", service.siid,
service.description, property.piid, property.description, propertyId);
if (service.siid == 1 && SKIP_SIID_1) {
continue;
}
if (property.access.contains("read") || property.access.contains("write")) {
MiIoBasicChannel miIoBasicChannel = new MiIoBasicChannel();
miIoBasicChannel
.setFriendlyName((isPureAscii(service.description) && !service.description.isBlank()
? captializedName(service.description)
: captializedName(serviceId))
+ " - "
+ (isPureAscii(property.description) && !property.description.isBlank()
? captializedName(property.description)
: captializedName(propertyId)));
miIoBasicChannel.setSiid(service.siid);
miIoBasicChannel.setPiid(property.piid);
// avoid duplicates and make camel case and avoid invalid channel names
String chanId = propertyId.replace(" ", "").replace(".", "_").replace("-", "_");
int cnt = 0;
while (propCheck.contains(chanId + Integer.toString(cnt))) {
cnt++;
}
propCheck.add(chanId.concat(Integer.toString(cnt)));
if (cnt > 0) {
chanId = chanId.concat(Integer.toString(cnt));
propertyId = propertyId.concat(Integer.toString(cnt));
logger.warn("duplicate for property:{} - {} ({}", chanId, property.description, cnt);
}
if (property.unit != null && !property.unit.isBlank()) {
if (!property.unit.contains("none")) {
miIoBasicChannel.setUnit(property.unit);
}
}
miIoBasicChannel.setProperty(propertyId);
miIoBasicChannel.setChannel(chanId);
switch (property.format) {
case "bool":
miIoBasicChannel.setType("Switch");
break;
case "uint8":
case "uint16":
case "uint32":
case "int8":
case "int16":
case "int32":
case "int64":
case "float":
StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
int decimals = -1;
String unit = "";
if (stateDescription == null) {
stateDescription = new StateDescriptionDTO();
}
String type = MiIoQuantiyTypesConversion.getType(property.unit);
if (type != null) {
miIoBasicChannel.setType("Number" + ":" + type);
unit = " %unit%";
decimals = property.format.contentEquals("float") ? 1 : 0;
} else {
miIoBasicChannel.setType("Number");
decimals = property.format.contentEquals("uint8") ? 0 : 1;
if (property.unit != null) {
unknownUnits.add(property.unit);
}
}
if (property.valueRange != null && property.valueRange.size() == 3) {
stateDescription
.setMinimum(BigDecimal.valueOf(property.valueRange.get(0).doubleValue()));
stateDescription
.setMaximum(BigDecimal.valueOf(property.valueRange.get(1).doubleValue()));
double step = property.valueRange.get(2).doubleValue();
if (step != 0) {
stateDescription.setStep(BigDecimal.valueOf(step));
if (step >= 1) {
decimals = 0;
}
}
}
if (decimals > -1) {
stateDescription.setPattern("%." + Integer.toString(decimals) + "f" + unit);
}
miIoBasicChannel.setStateDescription(stateDescription);
break;
case "string":
miIoBasicChannel.setType("String");
break;
case "hex":
miIoBasicChannel.setType("String");
logger.info("no type mapping implemented for {}", property.format);
break;
default:
miIoBasicChannel.setType("String");
logger.info("no type mapping for {}", property.format);
break;
}
miIoBasicChannel.setRefresh(property.access.contains("read"));
// add option values
if (property.valueList != null && property.valueList.size() > 0) {
StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
if (stateDescription == null) {
stateDescription = new StateDescriptionDTO();
}
stateDescription.setPattern(null);
List<OptionsValueListDTO> channeloptions = new LinkedList<>();
for (OptionsValueDescriptionsListDTO miotOption : property.valueList) {
// miIoBasicChannel.setValueList(property.valueList);
OptionsValueListDTO basicOption = new OptionsValueListDTO();
basicOption.setLabel(miotOption.getDescription());
basicOption.setValue(String.valueOf(miotOption.value));
channeloptions.add(basicOption);
}
stateDescription.setOptions(channeloptions);
miIoBasicChannel.setStateDescription(stateDescription);
// Add the mapping for the readme
StringBuilder mapping = new StringBuilder();
mapping.append("Value mapping [");
for (OptionsValueDescriptionsListDTO valueMap : property.valueList) {
mapping.append(String.format("\"%d\"=\"%s\",", valueMap.value, valueMap.description));
}
mapping.deleteCharAt(mapping.length() - 1);
mapping.append("]");
miIoBasicChannel.setReadmeComment(mapping.toString());
}
if (property.access.contains("write")) {
List<MiIoDeviceAction> miIoDeviceActions = new ArrayList<>();
MiIoDeviceAction action = new MiIoDeviceAction();
action.setCommand("set_properties");
switch (property.format) {
case "bool":
action.setparameterType(CommandParameterType.ONOFFBOOL);
break;
case "uint8":
case "int32":
case "float":
action.setparameterType(CommandParameterType.NUMBER);
break;
case "string":
action.setparameterType(CommandParameterType.STRING);
break;
default:
action.setparameterType(CommandParameterType.STRING);
break;
}
miIoDeviceActions.add(action);
miIoBasicChannel.setActions(miIoDeviceActions);
} else {
StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
if (stateDescription == null) {
stateDescription = new StateDescriptionDTO();
}
stateDescription.setReadOnly(true);
miIoBasicChannel.setStateDescription(stateDescription);
}
miIoBasicChannels.add(miIoBasicChannel);
} else {
logger.info("No reading siid: {}, description: {}, piid: {},description: {}", service.siid,
service.description, property.piid, property.description);
}
}
if (service.actions != null) {
for (ActionDTO action : service.actions) {
deviceActions.put(action, service);
String actionId = action.type.substring(action.type.indexOf("action:")).split(":")[1];
actionText.append("`action{");
actionText.append(String.format("\"did\":\"%s-%s\",", serviceId, actionId));
actionText.append(String.format("\"siid\":%d,", service.siid));
actionText.append(String.format("\"aiid\":%d,", action.iid));
actionText.append(String.format("\"in\":%s", action.in));
actionText.append("}`\r\n");
}
}
} else {
logger.info("SID: {}, description: {} has no identified properties", service.siid, service.description);
}
}
if (!deviceActions.isEmpty()) {
miIoBasicChannels.add(0, actionChannel(deviceActions));
}
deviceMapping.setChannels(miIoBasicChannels);
device.setDevice(deviceMapping);
if (actionText.length() > 35) {
deviceMapping.setReadmeComment(
"Identified " + actionText.toString().replace("Manual", "manual").replace("\r\n", "<br />")
+ "Please test and feedback if they are working to they can be linked to a channel.");
}
logger.info(channelConfigText.toString());
if (actionText.length() > 30) {
logger.info("{}", actionText);
} else {
logger.info("No actions defined for device");
}
unknownUnits.remove("none");
if (!unknownUnits.isEmpty()) {
logger.info("New units identified (inform developer): {}", String.join(", ", unknownUnits));
}
this.device = device;
return device;
}
private MiIoBasicChannel actionChannel(Map<ActionDTO, ServiceDTO> deviceActions) {
MiIoBasicChannel miIoBasicChannel = new MiIoBasicChannel();
if (!deviceActions.isEmpty()) {
miIoBasicChannel.setProperty("");
miIoBasicChannel.setChannel("actions");
miIoBasicChannel.setFriendlyName("Actions");
miIoBasicChannel.setType("String");
miIoBasicChannel.setRefresh(false);
StateDescriptionDTO stateDescription = new StateDescriptionDTO();
List<OptionsValueListDTO> options = new LinkedList<>();
List<MiIoDeviceAction> miIoDeviceActions = new LinkedList<>();
deviceActions.forEach((action, service) -> {
String actionId = action.type.substring(action.type.indexOf("action:")).split(":")[1];
String serviceId = service.type.substring(service.type.indexOf("service:")).split(":")[1];
String description = String.format("%s-%s", serviceId, actionId);
OptionsValueListDTO option = new OptionsValueListDTO();
option.label = captializedName(description);
option.value = description;
options.add(option);
MiIoDeviceAction miIoDeviceAction = new MiIoDeviceAction();
miIoDeviceAction.setCommand("action");
miIoDeviceAction.setparameterType(CommandParameterType.EMPTY);
miIoDeviceAction.setSiid(service.siid);
miIoDeviceAction.setAiid(action.iid);
if (!action.in.isEmpty()) {
miIoDeviceAction.setParameters(JsonParser.parseString(GSON.toJson(action.in)).getAsJsonArray());
miIoDeviceAction.setparameterType("fromparameter");
}
MiIoDeviceActionCondition miIoDeviceActionCondition = new MiIoDeviceActionCondition();
String json = String.format("[{ \"matchValue\"=\"%s\"}]", description);
miIoDeviceActionCondition.setName("matchValue");
miIoDeviceActionCondition.setParameters(JsonParser.parseString(json).getAsJsonArray());
miIoDeviceAction.setCondition(miIoDeviceActionCondition);
miIoDeviceActions.add(miIoDeviceAction);
});
stateDescription.setOptions(options);
miIoBasicChannel.setStateDescription(stateDescription);
miIoBasicChannel.setActions(miIoDeviceActions);
}
return miIoBasicChannel;
}
private static String captializedName(String name) {
if (name.isEmpty()) {
return name;
}
String str = name.replace("-", " ").replace(".", " ");
return Arrays.stream(str.split("\\s+")).map(t -> t.substring(0, 1).toUpperCase() + t.substring(1))
.collect(Collectors.joining(" "));
}
public static boolean isPureAscii(String v) {
return StandardCharsets.US_ASCII.newEncoder().canEncode(v);
}
private JsonElement getUrnData(String urn, HttpClient httpClient)
throws InterruptedException, TimeoutException, ExecutionException, JsonParseException {
ContentResponse response;
String urlStr = BASEURL + "instance?type=" + urn;
logger.info("miot info: {}", urlStr);
response = httpClient.newRequest(urlStr).timeout(15, TimeUnit.SECONDS).send();
JsonElement json = JsonParser.parseString(response.getContentAsString());
this.urnData = json;
return json;
}
private @Nullable String getURN(String model, HttpClient httpClient) {
ContentResponse response;
try {
response = httpClient.newRequest(BASEURL + "instances?status=released").timeout(15, TimeUnit.SECONDS)
.send();
JsonElement json = JsonParser.parseString(response.getContentAsString());
UrnsDTO data = GSON.fromJson(json, UrnsDTO.class);
for (ModelUrnsDTO device : data.getInstances()) {
if (device.getModel().contentEquals(model)) {
this.urn = device.getType();
return device.getType();
}
}
} catch (InterruptedException | TimeoutException | ExecutionException e) {
logger.debug("Failed downloading models: {}", e.getMessage());
} catch (JsonParseException e) {
logger.debug("Failed parsing downloading models: {}", e.getMessage());
}
return null;
}
public String getModel() {
return model;
}
public @Nullable String getUrn() {
return urn;
}
public @Nullable JsonElement getUrnData() {
return urnData;
}
public @Nullable MiIoBasicDevice getDevice() {
return device;
}
}

View File

@ -0,0 +1,63 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Model Urns DTO for miot spec file.
*
* To read http://miot-spec.org/miot-spec-v2/instances?status=released
*
* @author Marcel Verpaalen - Initial contribution
*/
@NonNullByDefault
public class ModelUrnsDTO {
@SerializedName("model")
@Expose
private String model = "";
@SerializedName("version")
@Expose
private Integer version = 0;
@SerializedName("type")
@Expose
private String type = "";
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
}

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Mapping properties from json for miot device info
*
* @author Marcel Verpaalen - Initial contribution
*/
@NonNullByDefault
public class OptionsValueDescriptionsListDTO {
@SerializedName("value")
@Expose
@Nullable
public Integer value;
@SerializedName("description")
@Expose
@Nullable
public String description;
public int getValue() {
final Integer val = this.value;
return val != null ? val.intValue() : 0;
}
public void setValue(Integer value) {
this.value = value;
}
public String getDescription() {
final String description = this.description;
return description != null ? description : "";
}
public void setDescription(String description) {
this.description = description;
}
}

View File

@ -0,0 +1,51 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.List;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Mapping properties from json for miot device info
*
* @author Marcel Verpaalen - Initial contribution
*/
public class PropertyDTO {
@SerializedName("iid")
@Expose
public Integer piid;
@SerializedName("type")
@Expose
public String type;
@SerializedName("description")
@Expose
public String description;
@SerializedName("format")
@Expose
public String format;
@SerializedName("access")
@Expose
public List<String> access = null;
@SerializedName("value-list")
@Expose
public List<OptionsValueDescriptionsListDTO> valueList = null;
@SerializedName("value-range")
@Expose
public List<Integer> valueRange = null;
@SerializedName("unit")
@Expose
public String unit;
}

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.List;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Mapping properties from json for miot device info
*
* @author Marcel Verpaalen - Initial contribution
*/
public class ServiceDTO {
@SerializedName("iid")
@Expose
public Integer siid;
@SerializedName("type")
@Expose
public String type;
@SerializedName("description")
@Expose
public String description;
@SerializedName("properties")
@Expose
public List<PropertyDTO> properties = null;
@SerializedName("actions")
@Expose
public List<ActionDTO> actions = null;
@SerializedName("events")
@Expose
public List<EventDTO> events = null;
}

View File

@ -0,0 +1,40 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal.miot;
import java.util.Collections;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
/**
* Urns DTO for miot spec file.
*
* To read http://miot-spec.org/miot-spec-v2/instances?status=released
*
* @author Marcel Verpaalen - Initial contribution
*/
@NonNullByDefault
public class UrnsDTO {
@SerializedName("instances")
@Expose
private List<ModelUrnsDTO> instances = Collections.emptyList();
public List<ModelUrnsDTO> getInstances() {
return instances;
}
}

View File

@ -26,13 +26,25 @@
<channel id="commands" typeId="commands"/>
<channel id="rpc" typeId="rpc"/>
<channel id="testcommands" typeId="testcommands"/>
<channel id="testmiot" typeId="testmiot"/>
</channels>
</channel-group-type>
<channel-type id="testcommands">
<item-type>Switch</item-type>
<label>(experimental)Execute test to find supported channels</label>
<description>(experimental)Execute test for all known properties to find channels supported by your device.</description>
<description>Execute test for all known properties to find channels supported by your device. Check your log, share
your results.</description>
<category>settings</category>
</channel-type>
<channel-type id="testmiot">
<item-type>Switch</item-type>
<label>(experimental) Create experimental support for new MIOT protocol devices</label>
<description>Create experimental support for MIOT protocol devices based on the online specification. Check your log,
share your results.</description>
<category>settings</category>
</channel-type>
</thing:thing-descriptions>

View File

@ -169,6 +169,39 @@
"actions": [],
"readmeComment": "Value mapping `[\"1\"\u003d\"Charging\",\"2\"\u003d\"Not Charging\",\"4\"\u003d\"Charging\",\"5\"\u003d\"Go Charging\"]`"
},
{
"property": "water-mode",
"siid": 18,
"piid": 20,
"friendlyName": "Water Mode",
"channel": "water-mode",
"type": "Number",
"stateDescription": {
"readOnly": true,
"options": [
{
"value": "1",
"label": "Low"
},
{
"value": "2",
"label": "Medium"
},
{
"value": "4",
"label": "High"
}
]
},
"refresh": true,
"actions": [
{
"command": "set_properties",
"parameterType": "NUMBER"
}
],
"readmeComment": "Value mapping [\"1\"\u003d\"Low\",\"2\"\u003d\"Medium\",\"4\"\u003d\"High\"]"
},
{
"property": "fault",
"siid": 3,

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal;
import static org.junit.jupiter.api.Assertions.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.miio.internal.miot.MiIoQuantiyTypesConversion;
/**
* Test case for {@link MiIoQuantiyTypesConversion}
*
* @author Marcel Verpaalen - Initial contribution
*
*/
@NonNullByDefault
public class MiIoQuantiyTypesConversionTest {
@Test
public void UnknownUnitTest() {
String unitName = "some none existent unit";
assertNull(MiIoQuantiyTypesConversion.getType(unitName));
}
@Test
public void NullUnitTest() {
String unitName = null;
assertNull(MiIoQuantiyTypesConversion.getType(unitName));
}
@Test
public void regularsUnitTest() {
String unitName = "minute";
assertEquals("Time", MiIoQuantiyTypesConversion.getType(unitName));
unitName = "Minute";
assertEquals("Time", MiIoQuantiyTypesConversion.getType(unitName));
}
}

View File

@ -0,0 +1,135 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.miio.internal;
import java.io.File;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map.Entry;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.junit.jupiter.api.Disabled;
import org.openhab.binding.miio.internal.basic.MiIoBasicDevice;
import org.openhab.binding.miio.internal.miot.MiotParseException;
import org.openhab.binding.miio.internal.miot.MiotParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* Supporting tool for creation of the json database files for miot devices
* *
* Run in IDE with 'run as java application' or run in command line as:
* mvn exec:java -Dexec.mainClass="org.openhab.binding.miio.internal.MiotJsonFileCreator" -Dexec.classpathScope="test"
* -Dexec.args="zhimi.humidifier.ca4"
*
* The argument is the model string to create the database file for.
* If the digit at the end of the model is omitted, it will try a range of devices
*
* @author Marcel Verpaalen - Initial contribution
*
*/
@NonNullByDefault
public class MiotJsonFileCreator {
private static final Logger LOGGER = LoggerFactory.getLogger(MiotJsonFileCreator.class);
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final String BASEDIR = "./src/main/resources/database/";
private static final String FILENAME_EXTENSION = "-miot.json";
private static final boolean OVERWRITE_EXISTING_DATABASE_FILE = false;
@Disabled
public static void main(String[] args) {
LinkedHashMap<String, String> checksums = new LinkedHashMap<>();
LinkedHashSet<String> models = new LinkedHashSet<>();
if (args.length > 0) {
models.add(args[0]);
}
String m = models.isEmpty() ? "" : (String) models.toArray()[0];
boolean scan = m.isEmpty() ? false : !Character.isDigit(m.charAt(m.length() - 1));
if (scan) {
for (int i = 1; i <= 12; i++) {
models.add(models.toArray()[0] + String.valueOf(i));
}
}
MiotParser miotParser;
for (String model : models) {
LOGGER.info("Processing: {}", model);
HttpClient httpClient = null;
try {
httpClient = new HttpClient(new SslContextFactory.Client());
httpClient.setFollowRedirects(false);
httpClient.start();
miotParser = MiotParser.parse(model, httpClient);
LOGGER.info("urn: ", miotParser.getUrn());
LOGGER.info("{}", miotParser.getUrnData());
MiIoBasicDevice device = miotParser.getDevice();
if (device != null) {
LOGGER.info("Device: {}", device);
String fileName = String.format("%s%s%s", BASEDIR, model, FILENAME_EXTENSION);
if (!OVERWRITE_EXISTING_DATABASE_FILE) {
int counter = 0;
while (new File(fileName).isFile()) {
fileName = String.format("%s%s-%d%s", BASEDIR, model, counter, FILENAME_EXTENSION);
counter++;
}
}
miotParser.writeDevice(fileName, device);
String channelsJson = GSON.toJson(device.getDevice().getChannels()).toString();
checksums.put(model, checksumMD5(channelsJson));
}
LOGGER.info("Finished");
} catch (MiotParseException e) {
LOGGER.info("Error processing model {}: {}", model, e.getMessage());
} catch (Exception e) {
LOGGER.info("Failed to initiate http Client: {}", e.getMessage());
} finally {
try {
if (httpClient != null && httpClient.isRunning()) {
httpClient.stop();
}
} catch (Exception e) {
// ignore
}
}
}
StringBuilder sb = new StringBuilder();
for (Entry<String, String> ch : checksums.entrySet()) {
sb.append(ch.getValue());
sb.append(" --> ");
sb.append(ch.getKey());
sb.append("\r\n");
}
LOGGER.info("Checksums for device comparisons\r\n{}", sb);
}
public static String checksumMD5(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(input.getBytes());
return Utils.getHex(md.digest());
} catch (NoSuchAlgorithmException e) {
return "No MD5";
}
}
}