[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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 271 additions and 40 deletions

View File

@ -88,12 +88,25 @@ or in case of unknown models include the model information e.g.:
Newer devices may not yet be supported.
However, many devices share large similarities with existing devices.
The binding allows to try/test if your new device is working with database files of older devices as well.
There are 2 ways to get unsupported devices working, by overriding the model with the model of a supported item or by test all known properties to see which are supported by your device.
## Substitute model for unsupported devices
Replace the model with the model which is already supported.
For this, first remove your unsupported thing. Manually add a miio:basic thing.
Besides the regular configuration (like ip address, token) the modelId needs to be provided.
Normally the modelId is populated with the model of your device, however in this case, use the modelId of a similar device.
Look at the openHAB forum, or the openHAB GitHub repository for the modelId of similar devices.
# Advanced: adding local database files to support new devices
## Supported property test
The unsupported device has a test channel with switch. When switching on, all known properties are tested, this may take few minutes.
A test report will be shown in the log and is saved in the userdata/miio folder.
If supported properties are found, an experimental database file is saved to the conf/misc/miio folder (see below chapter).
The thing will go offline and will come back online as basic device, supporting the found channels.
The database file may need to be modified to display the right channel names.
After validation, please share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
## Advanced: adding local database files to support new devices
Things using the basic handler (miio:basic things) are driven by json 'database' files.
This instructs the binding which channels to create, which properties and actions are associated with the channels etc.
@ -101,7 +114,7 @@ The conf/misc/miio (e.g. in Linux `/opt/openhab2/conf/misc/miio/`) is scanned fo
Note that local database files take preference over build-in ones, hence if a json file is local and in the database the local file will be used.
For format, please check the current database files in openHAB GitHub.
## Channels
# Channels
Depending on the device, different channels are available.

View File

@ -163,7 +163,7 @@ or in case of unknown models include the model information e.g.:
| Philips Light | miio:basic | [philips.light.lrceiling](#philips-light-lrceiling) | Yes | Experimental support. Please report back if all channels are functional. Preferably share the debug log of property refresh and command responses |
| Xiaomi PHILIPS Zhirui Smart LED Bulb E14 Candle Lamp White Crystal | miio:basic | [philips.light.candle2](#philips-light-candle2) | Yes | |
| philips.light.mono1 | miio:basic | [philips.light.mono1](#philips-light-mono1) | Yes | |
| Light | miio:basic | [philips.light.dlight](#philips-light-dlight) | Yes | Experimental support. Please report back if all channels are functional. Preferably share the debug log of property refresh and command responses |
| Philips Down Light | miio:basic | [philips.light.dlight](#philips-light-dlight) | Yes | Experimental support. Please report back if all channels are functional. Preferably share the debug log of property refresh and command responses |
| Philips Ceiling Light | miio:basic | [philips.light.mceil](#philips-light-mceil) | Yes | Experimental support. Please report back if all channels are functional. Preferably share the debug log of property refresh and command responses |
| Philips Ceiling Light | miio:basic | [philips.light.mceilm](#philips-light-mceilm) | Yes | Experimental support. Please report back if all channels are functional. Preferably share the debug log of property refresh and command responses |
| Philips Ceiling Light | miio:basic | [philips.light.mceils](#philips-light-mceils) | Yes | Experimental support. Please report back if all channels are functional. Preferably share the debug log of property refresh and command responses |
@ -311,11 +311,24 @@ or in case of unknown models include the model information e.g.:
Newer devices may not yet be supported.
However, many devices share large similarities with existing devices.
The binding allows to try/test if your new device is working with database files of older devices as well.
There are 2 ways to get unsupported devices working, by overriding the model with the model of a supported item or by test all known properties to see which are supported by your device.
## Substitute model for unsupported devices
Replace the model with the model which is already supported.
For this, first remove your unsupported thing. Manually add a miio:basic thing.
Besides the regular configuration (like ip address, token) the modelId needs to be provided.
Normally the modelId is populated with the model of your device, however in this case, use the modelId of a similar device.
Look at the openHAB forum, or the openHAB GitHub repository for the modelId of similar devices.
## Supported property test
The unsupported device has a test channel with switch. When switching on, all known properties are tested, this may take few minutes.
A test report will be shown in the log and is saved in the userdata/miio folder.
If supported properties are found, an experimental database file is saved to the conf/misc/miio folder (see below chapter).
The thing will go offline and will come back online as basic device, supporting the found channels.
The database file may need to be modified to display the right channel names.
After validation, please share the logfile and json files on the openHAB forum or the openHAB GitHub to build future support for this model.
# Advanced: adding local database files to support new devices
Things using the basic handler (miio:basic things) are driven by json 'database' files.
@ -1394,7 +1407,7 @@ e.g. `smarthome:send actionCommand 'upd_timer["1498595904821", "on"]'` would ena
| brightness | Dimmer | Brightness | |
| scene | Number | Scene | |
### Light (<a name="philips-light-dlight">philips.light.dlight</a>) Channels
### Philips Down Light (<a name="philips-light-dlight">philips.light.dlight</a>) Channels
| Channel | Type | Description | Comment |
|------------------|---------|-------------------------------------|------------|
@ -4211,12 +4224,12 @@ Dimmer brightness "Brightness" (G_light) {channel="miio:basic:light:brightness"}
Number scene "Scene" (G_light) {channel="miio:basic:light:scene"}
```
### Light (philips.light.dlight) item file lines
### Philips Down Light (philips.light.dlight) item file lines
note: Autogenerated example. Replace the id (light) in the channel with your own. Replace `basic` with `generic` in the thing UID depending on how your thing was discovered.
```java
Group G_light "Light" <status>
Group G_light "Philips Down Light" <status>
Switch on "Power" (G_light) {channel="miio:basic:light:on"}
Number mode "Mode" (G_light) {channel="miio:basic:light:mode"}
Number brightness "Brightness" (G_light) {channel="miio:basic:light:brightness"}

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>