]> git.basschouten.com Git - openhab-addons.git/blob
21e0dbdb967ff3ef2707a1575ea34b68110b53c7
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.miio.internal.handler;
14
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
16
17 import java.io.File;
18 import java.io.FileWriter;
19 import java.io.IOException;
20 import java.net.URL;
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;
31
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;
54
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;
60
61 /**
62  * The {@link MiIoUnsupportedHandler} is responsible for handling commands, which are
63  * sent to one of the channels.
64  *
65  * @author Marcel Verpaalen - Initial contribution
66  */
67 @NonNullByDefault
68 public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
69
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;
73
74     private final Logger logger = LoggerFactory.getLogger(MiIoUnsupportedHandler.class);
75     private final MiIoBindingConfiguration conf = getConfigAs(MiIoBindingConfiguration.class);
76
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;
83
84     private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
85         miIoScheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
86         return true;
87     });
88
89     public MiIoUnsupportedHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
90             CloudConnector cloudConnector, HttpClient httpClientFactory) {
91         super(thing, miIoDatabaseWatchService, cloudConnector);
92         this.httpClient = httpClientFactory;
93     }
94
95     @Override
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();
101             } else {
102                 logger.debug("Refresh {} skipped. Already refreshing", channelUID);
103             }
104             return;
105         }
106         if (channelUID.getId().equals(CHANNEL_POWER)) {
107             if (command.equals(OnOffType.ON)) {
108                 sendCommand("set_power[\"on\"]");
109             } else {
110                 sendCommand("set_power[\"off\"]");
111             }
112         }
113         if (handleCommandsChannels(channelUID, command)) {
114             return;
115         }
116         if (channelUID.getId().equals(CHANNEL_TESTCOMMANDS)) {
117             executeExperimentalCommands();
118         }
119         if (channelUID.getId().equals(CHANNEL_TESTMIOT)) {
120             executeCreateMiotTestFile();
121         }
122     }
123
124     @Override
125     protected synchronized void updateData() {
126         if (skipUpdate()) {
127             return;
128         }
129         logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
130         try {
131             refreshNetwork();
132         } catch (Exception e) {
133             logger.debug("Error while updating '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID(),
134                     e);
135         }
136     }
137
138     @Override
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();
145             }
146             miioinfo.remove("token");
147             miioinfo.remove("ap");
148             miioinfo.remove("mac");
149             info = miioinfo.toString();
150             sb.append(info);
151             sb.append("\r\n");
152         }
153         if (lastCommand >= response.getId()) {
154             sb.append(response.getCommandString());
155             sb.append(" -> ");
156             sb.append(response.getResponse());
157             sb.append("\r\n");
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);
163                 }
164             }
165         }
166         if (lastCommand >= 0 & lastCommand <= response.getId()) {
167             lastCommand = -1;
168             finishChannelTest();
169             updateState(CHANNEL_TESTCOMMANDS, OnOffType.OFF);
170         }
171     }
172
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);
180         sb.append("\r\n");
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();
187             sb.append(c);
188             sb.append(" -> ");
189             lastCommand = sendCommand(cmd);
190             sb.append(lastCommand);
191             sb.append(", ");
192             testChannelList.put(lastCommand, channelList.get(c));
193         }
194         this.lastCommand = lastCommand;
195         sb.append("\r\n");
196         logger.info("{}", sb.toString());
197     }
198
199     private void executeCreateMiotTestFile() {
200         sb = new StringBuilder();
201         try {
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);
209                 return;
210             }
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);
216             sb.append("\r\n");
217             sb.append("Database file created:");
218             sb.append(writeDevice(device, true));
219             sb.append("\r\n");
220             sb.append(MiotParser.toJson(device));
221             sb.append("\r\n");
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());
234                     sb.append(" -> ");
235                     sb.append(cmd);
236                     sb.append(" -> ");
237                     lastCommand = sendCommand(cmd);
238                     sb.append(lastCommand);
239                     sb.append(", \r\n");
240                     testChannelList.put(lastCommand, ch);
241                 }
242             }
243             this.lastCommand = lastCommand;
244             sb.append("\r\n");
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());
249         }
250     }
251
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;
258         }
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) {
262             try {
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);
267                     }
268                 }
269             } catch (IndexOutOfBoundsException e) {
270                 // swallow
271             }
272         }
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);
279                 }
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);
284                 }
285             }
286         }
287         return testChannelsList;
288     }
289
290     private List<MiIoBasicChannel> getBasicChannels(String deviceName) {
291         logger.debug("Adding Channels from model: {}", deviceName);
292         URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
293         if (fn == null) {
294             logger.warn("Database entry for model '{}' cannot be found.", deviceName);
295             return Collections.emptyList();
296         }
297         try {
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);
308         }
309         return Collections.emptyList();
310     }
311
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: ");
317         sb.append(info);
318         sb.append("\r\n");
319         sb.append(supportedChannelList.size());
320         sb.append(" channels with responses.\r\n");
321         int miotChannels = 0;
322         for (MiIoBasicChannel ch : supportedChannelList.keySet()) {
323             if (ch.isMiOt()) {
324                 miotChannels++;
325             }
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));
332             sb.append("\r\n");
333         }
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;
342         } else {
343             sb.append(isMiot ? "Miot file already created. Manually remove non-functional channels.\r\n"
344                     : "No supported channels found.\r\n");
345         }
346         sb.append("\r\nDevice testing file saved to: ");
347         sb.append(writeLog());
348         sb.append(
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());
351     }
352
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 }));
359         int duplicates = 0;
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);
366                 duplicates++;
367             }
368             chNames.add(channelName);
369         }
370         deviceMapping.setChannels(miIoBasicChannels);
371         device.setDevice(deviceMapping);
372         return device;
373     }
374
375     private String writeDevice(MiIoBasicDevice device, boolean miot) {
376         File folder = new File(BINDING_DATABASE_PATH);
377         if (!folder.exists()) {
378             folder.mkdirs();
379         }
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());
387         }
388         return "Failed creating database file";
389     }
390
391     private String writeLog() {
392         File folder = new File(BINDING_USERDATA_PATH);
393         if (!folder.exists()) {
394             folder.mkdirs();
395         }
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());
403         }
404         return "Failed creating testlog file";
405     }
406 }