2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.miio.internal.handler;
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
18 import java.io.FileWriter;
19 import java.io.IOException;
21 import java.time.LocalDateTime;
22 import java.time.format.DateTimeFormatter;
23 import java.util.ArrayList;
24 import java.util.Arrays;
25 import java.util.Collections;
26 import java.util.HashSet;
27 import java.util.LinkedHashMap;
28 import java.util.LinkedHashSet;
29 import java.util.List;
30 import java.util.concurrent.TimeUnit;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.eclipse.jetty.client.HttpClient;
35 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
36 import org.openhab.binding.miio.internal.MiIoCommand;
37 import org.openhab.binding.miio.internal.MiIoDevices;
38 import org.openhab.binding.miio.internal.MiIoSendCommand;
39 import org.openhab.binding.miio.internal.Utils;
40 import org.openhab.binding.miio.internal.basic.DeviceMapping;
41 import org.openhab.binding.miio.internal.basic.MiIoBasicChannel;
42 import org.openhab.binding.miio.internal.basic.MiIoBasicDevice;
43 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
44 import org.openhab.binding.miio.internal.cloud.CloudConnector;
45 import org.openhab.binding.miio.internal.miot.MiotParser;
46 import org.openhab.core.cache.ExpiringCache;
47 import org.openhab.core.library.types.OnOffType;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
55 import com.google.gson.Gson;
56 import com.google.gson.GsonBuilder;
57 import com.google.gson.JsonIOException;
58 import com.google.gson.JsonObject;
59 import com.google.gson.JsonSyntaxException;
62 * The {@link MiIoUnsupportedHandler} is responsible for handling commands, which are
63 * sent to one of the channels.
65 * @author Marcel Verpaalen - Initial contribution
68 public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
70 private static final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
71 private static final Gson GSONP = new GsonBuilder().setPrettyPrinting().create();
72 private final HttpClient httpClient;
74 private final Logger logger = LoggerFactory.getLogger(MiIoUnsupportedHandler.class);
75 private final MiIoBindingConfiguration conf = getConfigAs(MiIoBindingConfiguration.class);
77 private StringBuilder sb = new StringBuilder();
78 private String info = "";
79 private int lastCommand = -1;
80 private LinkedHashMap<Integer, MiIoBasicChannel> testChannelList = new LinkedHashMap<>();
81 private LinkedHashMap<MiIoBasicChannel, String> supportedChannelList = new LinkedHashMap<>();
82 private String model = conf.model;
84 private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
85 miIoScheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
89 public MiIoUnsupportedHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
90 CloudConnector cloudConnector, HttpClient httpClientFactory) {
91 super(thing, miIoDatabaseWatchService, cloudConnector);
92 this.httpClient = httpClientFactory;
96 public void handleCommand(ChannelUID channelUID, Command command) {
97 if (command == RefreshType.REFRESH) {
98 if (updateDataCache.isExpired()) {
99 logger.debug("Refreshing {}", channelUID);
100 updateDataCache.getValue();
102 logger.debug("Refresh {} skipped. Already refreshing", channelUID);
106 if (channelUID.getId().equals(CHANNEL_POWER)) {
107 if (command.equals(OnOffType.ON)) {
108 sendCommand("set_power[\"on\"]");
110 sendCommand("set_power[\"off\"]");
113 if (handleCommandsChannels(channelUID, command)) {
116 if (channelUID.getId().equals(CHANNEL_TESTCOMMANDS)) {
117 executeExperimentalCommands();
119 if (channelUID.getId().equals(CHANNEL_TESTMIOT)) {
120 executeCreateMiotTestFile();
125 protected synchronized void updateData() {
129 logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
132 } catch (Exception e) {
133 logger.debug("Error while updating '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID(),
139 public void onMessageReceived(MiIoSendCommand response) {
140 super.onMessageReceived(response);
141 if (MiIoCommand.MIIO_INFO.equals(response.getCommand()) && !response.isError()) {
142 JsonObject miioinfo = response.getResult().getAsJsonObject();
143 if (miioinfo.has("model")) {
144 model = miioinfo.get("model").getAsString();
146 miioinfo.remove("token");
147 miioinfo.remove("ap");
148 miioinfo.remove("mac");
149 info = miioinfo.toString();
153 if (lastCommand >= response.getId()) {
154 sb.append(response.getCommandString());
156 sb.append(response.getResponse());
158 String res = response.getResult().toString();
159 if (!response.isError() && !res.contentEquals("[null]") && !res.contentEquals("[]")
160 && !res.contentEquals("[\"\"]")) {
161 if (testChannelList.containsKey(response.getId())) {
162 supportedChannelList.put(testChannelList.get(response.getId()), res);
166 if (lastCommand >= 0 & lastCommand <= response.getId()) {
169 updateState(CHANNEL_TESTCOMMANDS, OnOffType.OFF);
173 private void executeExperimentalCommands() {
174 LinkedHashMap<String, MiIoBasicChannel> channelList = collectProperties(conf.model);
175 sendCommand(MiIoCommand.MIIO_INFO);
176 sb = new StringBuilder();
177 logger.info("Start experimental testing of supported properties for device '{}'. ", miDevice.toString());
178 sb.append("Info for ");
179 sb.append(conf.model);
181 sb.append("Properties: ");
182 int lastCommand = -1;
183 for (String c : channelList.keySet()) {
184 MiIoBasicChannel ch = channelList.get(c);
185 String cmd = ch.getChannelCustomRefreshCommand().isBlank() ? ("get_prop[" + c + "]")
186 : ch.getChannelCustomRefreshCommand();
189 lastCommand = sendCommand(cmd);
190 sb.append(lastCommand);
192 testChannelList.put(lastCommand, channelList.get(c));
194 this.lastCommand = lastCommand;
196 logger.info("{}", sb.toString());
199 private void executeCreateMiotTestFile() {
200 sb = new StringBuilder();
202 MiotParser miotParser;
203 miotParser = MiotParser.parse(model, httpClient);
204 logger.info("urn: {}", miotParser.getUrn());
205 logger.info("{}", miotParser.getUrnData());
206 MiIoBasicDevice device = miotParser.getDevice();
207 if (device == null) {
208 logger.debug("Error while creating experimental miot db file for {}.", conf.model);
211 sendCommand(MiIoCommand.MIIO_INFO);
212 logger.info("Start experimental creation of database file based on MIOT spec for device '{}'. ",
213 miDevice.toString());
214 sb.append("Info for ");
215 sb.append(conf.model);
217 sb.append("Database file created:");
218 sb.append(writeDevice(device, true));
220 sb.append(MiotParser.toJson(device));
222 sb.append("Testing Properties:\r\n");
223 int lastCommand = -1;
224 for (MiIoBasicChannel ch : device.getDevice().getChannels()) {
225 if (ch.isMiOt() && ch.getRefresh()) {
226 JsonObject json = new JsonObject();
227 json.addProperty("did", ch.getProperty());
228 json.addProperty("siid", ch.getSiid());
229 json.addProperty("piid", ch.getPiid());
230 String cmd = ch.getChannelCustomRefreshCommand().isBlank()
231 ? ("get_properties[" + json.toString() + "]")
232 : ch.getChannelCustomRefreshCommand();
233 sb.append(ch.getChannel());
237 lastCommand = sendCommand(cmd);
238 sb.append(lastCommand);
240 testChannelList.put(lastCommand, ch);
243 this.lastCommand = lastCommand;
245 logger.info("{}", sb.toString());
246 } catch (Exception e) {
247 logger.debug("Error while creating experimental miot db file for {}", conf.model);
248 logger.info("{}", sb.toString());
252 private LinkedHashMap<String, MiIoBasicChannel> collectProperties(@Nullable String model) {
253 LinkedHashMap<String, MiIoBasicChannel> testChannelsList = new LinkedHashMap<>();
254 LinkedHashSet<MiIoDevices> testDeviceList = new LinkedHashSet<>();
255 if (model == null || model.length() < 2) {
256 logger.info("Wait until the model is determined, than try again");
257 return testChannelsList;
259 // first add similar devices to test those channels first, then test all others
260 int[] subset = { model.length() - 1, model.lastIndexOf("."), model.indexOf("."), 0 };
261 for (int i : subset) {
263 final String mm = model.substring(0, model.lastIndexOf("."));
264 for (MiIoDevices dev : MiIoDevices.values()) {
265 if (dev.getThingType().equals(THING_TYPE_BASIC) && (i == 0 || dev.getModel().contains(mm))) {
266 testDeviceList.add(dev);
269 } catch (IndexOutOfBoundsException e) {
273 for (MiIoDevices dev : testDeviceList) {
274 for (MiIoBasicChannel ch : getBasicChannels(dev.getModel())) {
275 // Add all (unique) properties
276 if (!ch.isMiOt() && !ch.getProperty().isBlank() && ch.getChannelCustomRefreshCommand().isBlank()
277 && !testChannelsList.containsKey(ch.getProperty())) {
278 testChannelsList.putIfAbsent(ch.getProperty(), ch);
280 // Add all (unique) custom refresh commands
281 if (!ch.isMiOt() && !ch.getChannelCustomRefreshCommand().isBlank()
282 && !testChannelsList.containsKey(ch.getChannelCustomRefreshCommand())) {
283 testChannelsList.putIfAbsent(ch.getChannelCustomRefreshCommand(), ch);
287 return testChannelsList;
290 private List<MiIoBasicChannel> getBasicChannels(String deviceName) {
291 logger.debug("Adding Channels from model: {}", deviceName);
292 URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
294 logger.warn("Database entry for model '{}' cannot be found.", deviceName);
295 return Collections.emptyList();
298 JsonObject deviceMapping = Utils.convertFileToJSON(fn);
299 logger.debug("Using device database: {} for device {}", fn.getFile(), deviceName);
300 final MiIoBasicDevice device = GSONP.fromJson(deviceMapping, MiIoBasicDevice.class);
301 return device.getDevice().getChannels();
302 } catch (JsonIOException | JsonSyntaxException e) {
303 logger.warn("Error parsing database Json", e);
304 } catch (IOException e) {
305 logger.warn("Error reading database file", e);
306 } catch (Exception e) {
307 logger.warn("Error creating channel structure", e);
309 return Collections.emptyList();
312 private void finishChannelTest() {
313 sb.append("===================================\r\n");
314 sb.append("Responsive properties\r\n");
315 sb.append("===================================\r\n");
316 sb.append("Device Info: ");
319 sb.append(supportedChannelList.size());
320 sb.append(" channels with responses.\r\n");
321 int miotChannels = 0;
322 for (MiIoBasicChannel ch : supportedChannelList.keySet()) {
326 sb.append("Property: ");
327 sb.append(Utils.minLengthString(ch.getProperty(), 15));
328 sb.append(" Friendly Name: ");
329 sb.append(Utils.minLengthString(ch.getFriendlyName(), 25));
330 sb.append(" Response: ");
331 sb.append(supportedChannelList.get(ch));
334 boolean isMiot = miotChannels > supportedChannelList.size() / 2;
335 if (!supportedChannelList.isEmpty() && !isMiot) {
336 MiIoBasicDevice mbd = createBasicDeviceDb(model, new ArrayList<>(supportedChannelList.keySet()));
337 sb.append("Created experimental database for your device:\r\n");
338 sb.append(GSONP.toJson(mbd));
339 sb.append("\r\nDatabase file saved to: ");
340 sb.append(writeDevice(mbd, false));
341 isIdentified = false;
343 sb.append(isMiot ? "Miot file already created. Manually remove non-functional channels.\r\n"
344 : "No supported channels found.\r\n");
346 sb.append("\r\nDevice testing file saved to: ");
347 sb.append(writeLog());
349 "\r\nPlease share your these files on the community forum or github to get this device supported.\r\n");
350 logger.info("{}", sb.toString());
353 private MiIoBasicDevice createBasicDeviceDb(String model, List<MiIoBasicChannel> miIoBasicChannels) {
354 MiIoBasicDevice device = new MiIoBasicDevice();
355 DeviceMapping deviceMapping = new DeviceMapping();
356 deviceMapping.setPropertyMethod(MiIoCommand.GET_PROPERTY.getCommand());
357 deviceMapping.setMaxProperties(2);
358 deviceMapping.setId(Arrays.asList(new String[] { model }));
360 HashSet<String> chNames = new HashSet<>();
361 for (MiIoBasicChannel ch : miIoBasicChannels) {
362 String channelName = ch.getChannel();
363 if (chNames.contains(channelName)) {
364 channelName = channelName + duplicates;
365 ch.setChannel(channelName);
368 chNames.add(channelName);
370 deviceMapping.setChannels(miIoBasicChannels);
371 device.setDevice(deviceMapping);
375 private String writeDevice(MiIoBasicDevice device, boolean miot) {
376 File folder = new File(BINDING_DATABASE_PATH);
377 if (!folder.exists()) {
380 File dataFile = new File(folder, model + (miot ? "-miot" : "") + "-experimental.json");
381 try (FileWriter writer = new FileWriter(dataFile)) {
382 writer.write(miot ? MiotParser.toJson(device) : GSONP.toJson(device));
383 logger.debug("Experimental database file created: {}", dataFile.getAbsolutePath());
384 return dataFile.getAbsolutePath().toString();
385 } catch (IOException e) {
386 logger.info("Error writing database file {}: {}", dataFile.getAbsolutePath(), e.getMessage());
388 return "Failed creating database file";
391 private String writeLog() {
392 File folder = new File(BINDING_USERDATA_PATH);
393 if (!folder.exists()) {
396 File dataFile = new File(folder, "test-" + model + "-" + LocalDateTime.now().format(DATEFORMATTER) + ".txt");
397 try (FileWriter writer = new FileWriter(dataFile)) {
398 writer.write(sb.toString());
399 logger.debug("Saved device testing file to {}", dataFile.getAbsolutePath());
400 return dataFile.getAbsolutePath().toString();
401 } catch (IOException e) {
402 logger.info("Error writing file {}: {}", dataFile.getAbsolutePath(), e.getMessage());
404 return "Failed creating testlog file";