2 * Copyright (c) 2010-2023 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.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;
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;
64 * The {@link MiIoUnsupportedHandler} is responsible for handling commands, which are
65 * sent to one of the channels.
67 * @author Marcel Verpaalen - Initial contribution
70 public class MiIoUnsupportedHandler extends MiIoAbstractHandler {
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;
76 private final Logger logger = LoggerFactory.getLogger(MiIoUnsupportedHandler.class);
77 private final MiIoBindingConfiguration conf = getConfigAs(MiIoBindingConfiguration.class);
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;
86 private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
87 miIoScheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
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;
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();
105 logger.debug("Refresh {} skipped. Already refreshing", channelUID);
109 if (channelUID.getId().equals(CHANNEL_POWER)) {
110 if (command.equals(OnOffType.ON)) {
111 sendCommand("set_power[\"on\"]");
113 sendCommand("set_power[\"off\"]");
116 if (handleCommandsChannels(channelUID, command)) {
119 if (channelUID.getId().equals(CHANNEL_TESTCOMMANDS)) {
120 executeExperimentalCommands();
122 if (channelUID.getId().equals(CHANNEL_TESTMIOT)) {
123 executeCreateMiotTestFile();
128 protected synchronized void updateData() {
132 logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
135 } catch (Exception e) {
136 logger.debug("Error while updating '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID(),
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();
149 miioinfo.remove("token");
150 miioinfo.remove("ap");
151 miioinfo.remove("mac");
152 info = miioinfo.toString();
156 if (lastCommand >= response.getId()) {
157 sb.append(response.getCommandString());
159 sb.append(response.getResponse());
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);
169 if (lastCommand >= 0 & lastCommand <= response.getId()) {
172 updateState(CHANNEL_TESTCOMMANDS, OnOffType.OFF);
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);
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();
192 lastCommand = sendCommand(cmd);
193 sb.append(lastCommand);
195 testChannelList.put(lastCommand, channelList.get(c));
197 this.lastCommand = lastCommand;
199 logger.info("{}", sb.toString());
202 private void executeCreateMiotTestFile() {
203 sb = new StringBuilder();
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);
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);
220 sb.append("Database file created:");
221 sb.append(writeDevice(device, true));
223 sb.append(MiotParser.toJson(device));
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());
240 lastCommand = sendCommand(cmd);
241 sb.append(lastCommand);
243 testChannelList.put(lastCommand, ch);
246 this.lastCommand = lastCommand;
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());
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;
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) {
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);
272 } catch (IndexOutOfBoundsException e) {
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);
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);
290 return testChannelsList;
293 private List<MiIoBasicChannel> getBasicChannels(String deviceName) {
294 logger.debug("Adding Channels from model: {}", deviceName);
295 URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
297 logger.warn("Database entry for model '{}' cannot be found.", deviceName);
298 return Collections.emptyList();
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();
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);
314 return Collections.emptyList();
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: ");
324 sb.append(supportedChannelList.size());
325 sb.append(" channels with responses.\r\n");
326 int miotChannels = 0;
327 for (MiIoBasicChannel ch : supportedChannelList.keySet()) {
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));
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;
348 sb.append(isMiot ? "Miot file already created. Manually remove non-functional channels.\r\n"
349 : "No supported channels found.\r\n");
351 sb.append("\r\nDevice testing file saved to: ");
352 sb.append(writeLog());
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());
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 }));
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);
373 chNames.add(channelName);
375 deviceMapping.setChannels(miIoBasicChannels);
376 device.setDevice(deviceMapping);
380 private String writeDevice(MiIoBasicDevice device, boolean miot) {
381 File folder = new File(BINDING_DATABASE_PATH);
382 if (!folder.exists()) {
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());
393 return "Failed creating database file";
396 private String writeLog() {
397 File folder = new File(BINDING_USERDATA_PATH);
398 if (!folder.exists()) {
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());
409 return "Failed creating testlog file";