[miio] Improved unsupported handler that build experimental support (#8716)

* [miio] Improved unsupported handler that build experimental support

New unsupported handler that tries to find channels for unsupported
devices and creates experimental database file for it.

* [miio] logger fixes

Signed-off-by: Marcel Verpaalen <marcel@verpaalen.com>
This commit is contained in:
Marcel
2020-10-10 20:03:33 +02:00
committed by GitHub
parent 54791d97d0
commit aa98737e9f
6 changed files with 271 additions and 40 deletions

View File

@@ -12,12 +12,14 @@
*/
package org.openhab.binding.miio.internal;
import java.io.File;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.OpenHAB;
import org.openhab.core.thing.ThingTypeUID;
/**
@@ -111,4 +113,8 @@ public final class MiIoBindingConstants {
.of("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", "00000000000000000000000000000000").collect(Collectors.toSet()));
public static final String DATABASE_PATH = "database/";
public static final String BINDING_DATABASE_PATH = OpenHAB.getConfigFolder() + File.separator + "misc"
+ File.separator + BINDING_ID;
public static final String BINDING_USERDATA_PATH = OpenHAB.getUserDataFolder() + File.separator
+ MiIoBindingConstants.BINDING_ID;
}

View File

@@ -97,4 +97,8 @@ public final class Utils {
jsonObject = jsonElement.getAsJsonObject();
return jsonObject;
}
public static String minLengthString(String string, int length) {
return String.format("%-" + length + "s", string);
}
}

View File

@@ -14,9 +14,30 @@ package org.openhab.binding.miio.internal.handler;
import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URL;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
import org.openhab.binding.miio.internal.MiIoCommand;
import org.openhab.binding.miio.internal.MiIoDevices;
import org.openhab.binding.miio.internal.MiIoSendCommand;
import org.openhab.binding.miio.internal.Utils;
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.MiIoDatabaseWatchService;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.library.types.OnOffType;
@@ -27,6 +48,12 @@ import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
/**
* The {@link MiIoUnsupportedHandler} is responsible for handling commands, which are
* sent to one of the channels.
@@ -35,7 +62,19 @@ import org.slf4j.LoggerFactory;
*/
@NonNullByDefault
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 Logger logger = LoggerFactory.getLogger(MiIoUnsupportedHandler.class);
private final MiIoBindingConfiguration conf = getConfigAs(MiIoBindingConfiguration.class);
private StringBuilder sb = new StringBuilder();
private String info = "";
private int lastCommand = -1;
private LinkedHashMap<Integer, MiIoBasicChannel> testChannelList = new LinkedHashMap<>();
private LinkedHashMap<MiIoBasicChannel, String> supportedChannelList = new LinkedHashMap<>();
private String model = conf.model != null ? conf.model : "";
private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
scheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
@@ -72,38 +111,6 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
}
}
// TODO: In future version this ideally would test all known commands (e.g. from the database) and create/enable a
// channel if they appear to be supported
private void executeExperimentalCommands() {
String[] testCommands = new String[0];
switch (miDevice) {
case POWERPLUG:
case POWERPLUG2:
case POWERSTRIP:
case POWERSTRIP2:
case YEELIGHT_C1:
break;
case VACUUM:
testCommands = new String[] { "miIO.info", "get_current_sound", "get_map_v1", "get_serial_number",
"get_timezone" };
break;
case AIR_PURIFIERM:
case AIR_PURIFIER1:
case AIR_PURIFIER2:
case AIR_PURIFIER3:
case AIR_PURIFIER6:
break;
default:
testCommands = new String[] { "miIO.info" };
break;
}
logger.info("Start Experimental Testing of commands for device '{}'. ", miDevice.toString());
for (String c : testCommands) {
logger.info("Test command '{}'. Response: '{}'", c, sendCommand(c));
}
}
@Override
protected synchronized void updateData() {
if (skipUpdate()) {
@@ -117,4 +124,192 @@ public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
e);
}
}
@Override
public void onMessageReceived(MiIoSendCommand response) {
super.onMessageReceived(response);
if (MiIoCommand.MIIO_INFO.equals(response.getCommand()) && !response.isError()) {
JsonObject miioinfo = response.getResult().getAsJsonObject();
if (miioinfo.has("model")) {
model = miioinfo.get("model").getAsString();
}
miioinfo.remove("token");
miioinfo.remove("ap");
miioinfo.remove("mac");
info = miioinfo.toString();
sb.append(info);
sb.append("\r\n");
}
if (lastCommand >= response.getId()) {
sb.append(response.getCommandString());
sb.append(" -> ");
sb.append(response.getResponse());
sb.append("\r\n");
String res = response.getResult().toString();
if (!response.isError() && !res.contentEquals("[null]") && !res.contentEquals("[]")) {
if (testChannelList.containsKey(response.getId())) {
supportedChannelList.put(testChannelList.get(response.getId()), res);
}
}
}
if (lastCommand >= 0 & lastCommand <= response.getId()) {
lastCommand = -1;
finishChannelTest();
updateState(CHANNEL_TESTCOMMANDS, OnOffType.OFF);
}
}
private void executeExperimentalCommands() {
LinkedHashMap<String, MiIoBasicChannel> channelList = collectProperties(conf.model);
sendCommand(MiIoCommand.MIIO_INFO);
sb = new StringBuilder();
logger.info("Start experimental testing of supported properties for device '{}'. ", miDevice.toString());
sb.append("Info for ");
sb.append(conf.model);
sb.append("\r\n");
sb.append("Properties: ");
int lastCommand = -1;
for (String c : channelList.keySet()) {
String cmd = "get_prop[" + c + "]";
sb.append(c);
sb.append(" -> ");
lastCommand = sendCommand(cmd);
sb.append(lastCommand);
sb.append(", ");
testChannelList.put(lastCommand, channelList.get(c));
}
this.lastCommand = lastCommand;
sb.append("\r\n");
logger.info("{}", sb.toString());
}
private LinkedHashMap<String, MiIoBasicChannel> collectProperties(String model) {
LinkedHashMap<String, MiIoBasicChannel> testChannelsList = new LinkedHashMap<>();
LinkedHashSet<MiIoDevices> testDeviceList = new LinkedHashSet<>();
// first add similar devices to test those channels first, then test all others
int[] subset = { model.length() - 1, model.lastIndexOf("."), model.indexOf("."), 0 };
for (int i : subset) {
try {
final String mm = model.substring(0, model.lastIndexOf("."));
for (MiIoDevices dev : MiIoDevices.values()) {
if (dev.getThingType().equals(THING_TYPE_BASIC) && (i == 0 || dev.getModel().contains(mm))) {
testDeviceList.add(dev);
}
}
} catch (IndexOutOfBoundsException e) {
// swallow
}
}
for (MiIoDevices dev : testDeviceList) {
for (MiIoBasicChannel ch : getBasicChannels(dev.getModel())) {
if (!ch.isMiOt() && !ch.getProperty().isBlank() && !testChannelsList.containsKey(ch.getProperty())) {
testChannelsList.put(ch.getProperty(), ch);
}
}
}
return testChannelsList;
}
private List<MiIoBasicChannel> getBasicChannels(String deviceName) {
logger.debug("Adding Channels from model: {}", deviceName);
URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
if (fn == null) {
logger.warn("Database entry for model '{}' cannot be found.", deviceName);
return Collections.emptyList();
}
try {
JsonObject deviceMapping = Utils.convertFileToJSON(fn);
logger.debug("Using device database: {} for device {}", fn.getFile(), deviceName);
final MiIoBasicDevice device = GSONP.fromJson(deviceMapping, MiIoBasicDevice.class);
return device.getDevice().getChannels();
} catch (JsonIOException | JsonSyntaxException e) {
logger.warn("Error parsing database Json", e);
} catch (IOException e) {
logger.warn("Error reading database file", e);
} catch (Exception e) {
logger.warn("Error creating channel structure", e);
}
return Collections.emptyList();
}
private void finishChannelTest() {
sb.append("===================================\r\n");
sb.append("Responsive properties\r\n");
sb.append("===================================\r\n");
sb.append("Device Info: ");
sb.append(info);
for (MiIoBasicChannel ch : supportedChannelList.keySet()) {
sb.append("Property: ");
sb.append(Utils.minLengthString(ch.getProperty(), 15));
sb.append(" Friendly Name: ");
sb.append(Utils.minLengthString(ch.getFriendlyName(), 25));
sb.append(" Response: ");
sb.append(supportedChannelList.get(ch));
sb.append("\r\n");
}
if (supportedChannelList.size() > 0) {
MiIoBasicDevice mbd = createBasicDeviceDb(model, new ArrayList<>(supportedChannelList.keySet()));
writeDevice(mbd);
sb.append("Created experimental database for your device:\r\n");
sb.append(GSONP.toJson(mbd));
isIdentified = false;
} else {
sb.append("No supported channels found.\r\n");
}
sb.append(
"\r\nPlease share your this output on the community forum or github to get this device supported.\r\n");
logger.info("{}", sb.toString());
writeLog();
}
private MiIoBasicDevice createBasicDeviceDb(String model, List<MiIoBasicChannel> miIoBasicChannels) {
MiIoBasicDevice device = new MiIoBasicDevice();
DeviceMapping deviceMapping = new DeviceMapping();
deviceMapping.setPropertyMethod(MiIoCommand.GET_PROPERTY.getCommand());
deviceMapping.setMaxProperties(2);
deviceMapping.setId(Arrays.asList(new String[] { model }));
int duplicates = 0;
HashSet<String> chNames = new HashSet<>();
for (MiIoBasicChannel ch : miIoBasicChannels) {
String channelName = ch.getChannel();
if (chNames.contains(channelName)) {
channelName = channelName + duplicates;
ch.setChannel(channelName);
duplicates++;
}
chNames.add(channelName);
}
deviceMapping.setChannels(miIoBasicChannels);
device.setDevice(deviceMapping);
return device;
}
private void writeDevice(MiIoBasicDevice device) {
File folder = new File(BINDING_DATABASE_PATH);
if (!folder.exists()) {
folder.mkdirs();
}
File dataFile = new File(folder, model + "-experimental.json");
try (FileWriter writer = new FileWriter(dataFile)) {
writer.write(GSONP.toJson(device));
logger.info("Database file created: {}", dataFile.getAbsolutePath());
} catch (IOException e) {
logger.info("Error writing database file {}: {}", dataFile.getAbsolutePath(), e.getMessage());
}
}
private void writeLog() {
File folder = new File(BINDING_USERDATA_PATH);
if (!folder.exists()) {
folder.mkdirs();
}
File dataFile = new File(folder, "test-" + model + "-" + LocalDateTime.now().format(DATEFORMATTER) + ".txt");
try (FileWriter writer = new FileWriter(dataFile)) {
writer.write(sb.toString());
logger.info("Saved device testing file to {}", dataFile.getAbsolutePath());
} catch (IOException e) {
logger.info("Error writing file {}: {}", dataFile.getAbsolutePath(), e.getMessage());
}
}
}

View File

@@ -30,7 +30,7 @@
<channel-type id="testcommands">
<item-type>Switch</item-type>
<label>(experimental)Execute Test Commands</label>
<description>(experimental)Execute Test Commands to support development for your device. (NB device can switch modes)</description>
<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>
</channel-type>
</thing:thing-descriptions>