]> git.basschouten.com Git - openhab-addons.git/blob
838469ca63319a623347c5dbc4f59eb81108ba94
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.i18n.LocaleProvider;
48 import org.openhab.core.i18n.TranslationProvider;
49 import org.openhab.core.library.types.OnOffType;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.types.Command;
53 import org.openhab.core.types.RefreshType;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 import com.google.gson.Gson;
58 import com.google.gson.GsonBuilder;
59 import com.google.gson.JsonIOException;
60 import com.google.gson.JsonObject;
61 import com.google.gson.JsonSyntaxException;
62
63 /**
64  * The {@link MiIoUnsupportedHandler} is responsible for handling commands, which are
65  * sent to one of the channels.
66  *
67  * @author Marcel Verpaalen - Initial contribution
68  */
69 @NonNullByDefault
70 public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
71
72     private static final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
73     private static final Gson GSONP = new GsonBuilder().setPrettyPrinting().create();
74     private final HttpClient httpClient;
75
76     private final Logger logger = LoggerFactory.getLogger(MiIoUnsupportedHandler.class);
77     private final MiIoBindingConfiguration conf = getConfigAs(MiIoBindingConfiguration.class);
78
79     private StringBuilder sb = new StringBuilder();
80     private String info = "";
81     private int lastCommand = -1;
82     private LinkedHashMap<Integer, MiIoBasicChannel> testChannelList = new LinkedHashMap<>();
83     private LinkedHashMap<MiIoBasicChannel, String> supportedChannelList = new LinkedHashMap<>();
84     private String model = conf.model;
85
86     private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
87         miIoScheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
88         return true;
89     });
90
91     public MiIoUnsupportedHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
92             CloudConnector cloudConnector, HttpClient httpClientFactory, TranslationProvider i18nProvider,
93             LocaleProvider localeProvider) {
94         super(thing, miIoDatabaseWatchService, cloudConnector, i18nProvider, localeProvider);
95         this.httpClient = httpClientFactory;
96     }
97
98     @Override
99     public void handleCommand(ChannelUID channelUID, Command command) {
100         if (command == RefreshType.REFRESH) {
101             if (updateDataCache.isExpired()) {
102                 logger.debug("Refreshing {}", channelUID);
103                 updateDataCache.getValue();
104             } else {
105                 logger.debug("Refresh {} skipped. Already refreshing", channelUID);
106             }
107             return;
108         }
109         if (channelUID.getId().equals(CHANNEL_POWER)) {
110             if (command.equals(OnOffType.ON)) {
111                 sendCommand("set_power[\"on\"]");
112             } else {
113                 sendCommand("set_power[\"off\"]");
114             }
115         }
116         if (handleCommandsChannels(channelUID, command)) {
117             return;
118         }
119         if (channelUID.getId().equals(CHANNEL_TESTCOMMANDS)) {
120             executeExperimentalCommands();
121         }
122         if (channelUID.getId().equals(CHANNEL_TESTMIOT)) {
123             executeCreateMiotTestFile();
124         }
125     }
126
127     @Override
128     protected synchronized void updateData() {
129         if (skipUpdate()) {
130             return;
131         }
132         logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
133         try {
134             refreshNetwork();
135         } catch (Exception e) {
136             logger.debug("Error while updating '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID(),
137                     e);
138         }
139     }
140
141     @Override
142     public void onMessageReceived(MiIoSendCommand response) {
143         super.onMessageReceived(response);
144         if (MiIoCommand.MIIO_INFO.equals(response.getCommand()) && !response.isError()) {
145             JsonObject miioinfo = response.getResult().getAsJsonObject();
146             if (miioinfo.has("model")) {
147                 model = miioinfo.get("model").getAsString();
148             }
149             miioinfo.remove("token");
150             miioinfo.remove("ap");
151             miioinfo.remove("mac");
152             info = miioinfo.toString();
153             sb.append(info);
154             sb.append("\r\n");
155         }
156         if (lastCommand >= response.getId()) {
157             sb.append(response.getCommandString());
158             sb.append(" -> ");
159             sb.append(response.getResponse());
160             sb.append("\r\n");
161             String res = response.getResult().toString();
162             if (!response.isError() && !res.contentEquals("[null]") && !res.contentEquals("[]")
163                     && !res.contentEquals("[\"\"]")) {
164                 if (testChannelList.containsKey(response.getId())) {
165                     supportedChannelList.put(testChannelList.get(response.getId()), res);
166                 }
167             }
168         }
169         if (lastCommand >= 0 & lastCommand <= response.getId()) {
170             lastCommand = -1;
171             finishChannelTest();
172             updateState(CHANNEL_TESTCOMMANDS, OnOffType.OFF);
173         }
174     }
175
176     private void executeExperimentalCommands() {
177         LinkedHashMap<String, MiIoBasicChannel> channelList = collectProperties(conf.model);
178         sendCommand(MiIoCommand.MIIO_INFO);
179         sb = new StringBuilder();
180         logger.info("Start experimental testing of supported properties for device '{}'. ", miDevice.toString());
181         sb.append("Info for ");
182         sb.append(conf.model);
183         sb.append("\r\n");
184         sb.append("Properties: ");
185         int lastCommand = -1;
186         for (String c : channelList.keySet()) {
187             MiIoBasicChannel ch = channelList.get(c);
188             String cmd = ch.getChannelCustomRefreshCommand().isBlank() ? ("get_prop[" + c + "]")
189                     : ch.getChannelCustomRefreshCommand();
190             sb.append(c);
191             sb.append(" -> ");
192             lastCommand = sendCommand(cmd);
193             sb.append(lastCommand);
194             sb.append(", ");
195             testChannelList.put(lastCommand, channelList.get(c));
196         }
197         this.lastCommand = lastCommand;
198         sb.append("\r\n");
199         logger.info("{}", sb.toString());
200     }
201
202     private void executeCreateMiotTestFile() {
203         sb = new StringBuilder();
204         try {
205             MiotParser miotParser;
206             miotParser = MiotParser.parse(model, httpClient);
207             logger.info("urn: {}", miotParser.getUrn());
208             logger.info("{}", miotParser.getUrnData());
209             MiIoBasicDevice device = miotParser.getDevice();
210             if (device == null) {
211                 logger.debug("Error while creating experimental miot db file for {}.", conf.model);
212                 return;
213             }
214             sendCommand(MiIoCommand.MIIO_INFO);
215             logger.info("Start experimental creation of database file based on MIOT spec for device '{}'. ",
216                     miDevice.toString());
217             sb.append("Info for ");
218             sb.append(conf.model);
219             sb.append("\r\n");
220             sb.append("Database file created:");
221             sb.append(writeDevice(device, true));
222             sb.append("\r\n");
223             sb.append(MiotParser.toJson(device));
224             sb.append("\r\n");
225             sb.append("Testing Properties:\r\n");
226             int lastCommand = -1;
227             for (MiIoBasicChannel ch : device.getDevice().getChannels()) {
228                 if (ch.isMiOt() && ch.getRefresh()) {
229                     JsonObject json = new JsonObject();
230                     json.addProperty("did", ch.getProperty());
231                     json.addProperty("siid", ch.getSiid());
232                     json.addProperty("piid", ch.getPiid());
233                     String cmd = ch.getChannelCustomRefreshCommand().isBlank()
234                             ? ("get_properties[" + json.toString() + "]")
235                             : ch.getChannelCustomRefreshCommand();
236                     sb.append(ch.getChannel());
237                     sb.append(" -> ");
238                     sb.append(cmd);
239                     sb.append(" -> ");
240                     lastCommand = sendCommand(cmd);
241                     sb.append(lastCommand);
242                     sb.append(", \r\n");
243                     testChannelList.put(lastCommand, ch);
244                 }
245             }
246             this.lastCommand = lastCommand;
247             sb.append("\r\n");
248             logger.info("{}", sb.toString());
249         } catch (Exception e) {
250             logger.debug("Error while creating experimental miot db file for {}", conf.model);
251             logger.info("{}", sb.toString());
252         }
253     }
254
255     private LinkedHashMap<String, MiIoBasicChannel> collectProperties(@Nullable String model) {
256         LinkedHashMap<String, MiIoBasicChannel> testChannelsList = new LinkedHashMap<>();
257         LinkedHashSet<MiIoDevices> testDeviceList = new LinkedHashSet<>();
258         if (model == null || model.length() < 2) {
259             logger.info("Wait until the model is determined, than try again");
260             return testChannelsList;
261         }
262         // first add similar devices to test those channels first, then test all others
263         int[] subset = { model.length() - 1, model.lastIndexOf("."), model.indexOf("."), 0 };
264         for (int i : subset) {
265             try {
266                 final String mm = model.substring(0, model.lastIndexOf("."));
267                 for (MiIoDevices dev : MiIoDevices.values()) {
268                     if (dev.getThingType().equals(THING_TYPE_BASIC) && (i == 0 || dev.getModel().contains(mm))) {
269                         testDeviceList.add(dev);
270                     }
271                 }
272             } catch (IndexOutOfBoundsException e) {
273                 // swallow
274             }
275         }
276         for (MiIoDevices dev : testDeviceList) {
277             for (MiIoBasicChannel ch : getBasicChannels(dev.getModel())) {
278                 // Add all (unique) properties
279                 if (!ch.isMiOt() && !ch.getProperty().isBlank() && ch.getChannelCustomRefreshCommand().isBlank()
280                         && !testChannelsList.containsKey(ch.getProperty())) {
281                     testChannelsList.putIfAbsent(ch.getProperty(), ch);
282                 }
283                 // Add all (unique) custom refresh commands
284                 if (!ch.isMiOt() && !ch.getChannelCustomRefreshCommand().isBlank()
285                         && !testChannelsList.containsKey(ch.getChannelCustomRefreshCommand())) {
286                     testChannelsList.putIfAbsent(ch.getChannelCustomRefreshCommand(), ch);
287                 }
288             }
289         }
290         return testChannelsList;
291     }
292
293     private List<MiIoBasicChannel> getBasicChannels(String deviceName) {
294         logger.debug("Adding Channels from model: {}", deviceName);
295         URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
296         if (fn == null) {
297             logger.warn("Database entry for model '{}' cannot be found.", deviceName);
298             return Collections.emptyList();
299         }
300         try {
301             JsonObject deviceMapping = Utils.convertFileToJSON(fn);
302             logger.debug("Using device database: {} for device {}", fn.getFile(), deviceName);
303             final MiIoBasicDevice device = GSONP.fromJson(deviceMapping, MiIoBasicDevice.class);
304             if (device != null) {
305                 return device.getDevice().getChannels();
306             }
307         } catch (JsonIOException | JsonSyntaxException e) {
308             logger.warn("Error parsing database Json", e);
309         } catch (IOException e) {
310             logger.warn("Error reading database file", e);
311         } catch (Exception e) {
312             logger.warn("Error creating channel structure", e);
313         }
314         return Collections.emptyList();
315     }
316
317     private void finishChannelTest() {
318         sb.append("===================================\r\n");
319         sb.append("Responsive properties\r\n");
320         sb.append("===================================\r\n");
321         sb.append("Device Info: ");
322         sb.append(info);
323         sb.append("\r\n");
324         sb.append(supportedChannelList.size());
325         sb.append(" channels with responses.\r\n");
326         int miotChannels = 0;
327         for (MiIoBasicChannel ch : supportedChannelList.keySet()) {
328             if (ch.isMiOt()) {
329                 miotChannels++;
330             }
331             sb.append("Property: ");
332             sb.append(Utils.minLengthString(ch.getProperty(), 15));
333             sb.append(" Friendly Name: ");
334             sb.append(Utils.minLengthString(ch.getFriendlyName(), 25));
335             sb.append(" Response: ");
336             sb.append(supportedChannelList.get(ch));
337             sb.append("\r\n");
338         }
339         boolean isMiot = miotChannels > supportedChannelList.size() / 2;
340         if (!supportedChannelList.isEmpty() && !isMiot) {
341             MiIoBasicDevice mbd = createBasicDeviceDb(model, new ArrayList<>(supportedChannelList.keySet()));
342             sb.append("Created experimental database for your device:\r\n");
343             sb.append(GSONP.toJson(mbd));
344             sb.append("\r\nDatabase file saved to: ");
345             sb.append(writeDevice(mbd, false));
346             isIdentified = false;
347         } else {
348             sb.append(isMiot ? "Miot file already created. Manually remove non-functional channels.\r\n"
349                     : "No supported channels found.\r\n");
350         }
351         sb.append("\r\nDevice testing file saved to: ");
352         sb.append(writeLog());
353         sb.append(
354                 "\r\nPlease share your these files on the community forum or github to get this device supported.\r\n");
355         logger.info("{}", sb.toString());
356     }
357
358     private MiIoBasicDevice createBasicDeviceDb(String model, List<MiIoBasicChannel> miIoBasicChannels) {
359         MiIoBasicDevice device = new MiIoBasicDevice();
360         DeviceMapping deviceMapping = new DeviceMapping();
361         deviceMapping.setPropertyMethod(MiIoCommand.GET_PROPERTY.getCommand());
362         deviceMapping.setMaxProperties(2);
363         deviceMapping.setId(Arrays.asList(new String[] { model }));
364         int duplicates = 0;
365         HashSet<String> chNames = new HashSet<>();
366         for (MiIoBasicChannel ch : miIoBasicChannels) {
367             String channelName = ch.getChannel();
368             if (chNames.contains(channelName)) {
369                 channelName = channelName + duplicates;
370                 ch.setChannel(channelName);
371                 duplicates++;
372             }
373             chNames.add(channelName);
374         }
375         deviceMapping.setChannels(miIoBasicChannels);
376         device.setDevice(deviceMapping);
377         return device;
378     }
379
380     private String writeDevice(MiIoBasicDevice device, boolean miot) {
381         File folder = new File(BINDING_DATABASE_PATH);
382         if (!folder.exists()) {
383             folder.mkdirs();
384         }
385         File dataFile = new File(folder, model + (miot ? "-miot" : "") + "-experimental.json");
386         try (FileWriter writer = new FileWriter(dataFile)) {
387             writer.write(miot ? MiotParser.toJson(device) : GSONP.toJson(device));
388             logger.debug("Experimental database file created: {}", dataFile.getAbsolutePath());
389             return dataFile.getAbsolutePath().toString();
390         } catch (IOException e) {
391             logger.info("Error writing database file {}: {}", dataFile.getAbsolutePath(), e.getMessage());
392         }
393         return "Failed creating database file";
394     }
395
396     private String writeLog() {
397         File folder = new File(BINDING_USERDATA_PATH);
398         if (!folder.exists()) {
399             folder.mkdirs();
400         }
401         File dataFile = new File(folder, "test-" + model + "-" + LocalDateTime.now().format(DATEFORMATTER) + ".txt");
402         try (FileWriter writer = new FileWriter(dataFile)) {
403             writer.write(sb.toString());
404             logger.debug("Saved device testing file to {}", dataFile.getAbsolutePath());
405             return dataFile.getAbsolutePath().toString();
406         } catch (IOException e) {
407             logger.info("Error writing file {}: {}", dataFile.getAbsolutePath(), e.getMessage());
408         }
409         return "Failed creating testlog file";
410     }
411 }