]> git.basschouten.com Git - openhab-addons.git/blob
0686e206c592eaa17fb7dff083674fe0fad960c0
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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
14 package org.openhab.binding.miio.internal.miot;
15
16 import java.io.FileNotFoundException;
17 import java.io.PrintWriter;
18 import java.math.BigDecimal;
19 import java.nio.charset.StandardCharsets;
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.HashSet;
23 import java.util.LinkedHashMap;
24 import java.util.LinkedList;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.Set;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31 import java.util.stream.Collectors;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.openhab.binding.miio.internal.MiIoCommand;
38 import org.openhab.binding.miio.internal.basic.CommandParameterType;
39 import org.openhab.binding.miio.internal.basic.DeviceMapping;
40 import org.openhab.binding.miio.internal.basic.MiIoBasicChannel;
41 import org.openhab.binding.miio.internal.basic.MiIoBasicDevice;
42 import org.openhab.binding.miio.internal.basic.MiIoDeviceAction;
43 import org.openhab.binding.miio.internal.basic.MiIoDeviceActionCondition;
44 import org.openhab.binding.miio.internal.basic.OptionsValueListDTO;
45 import org.openhab.binding.miio.internal.basic.StateDescriptionDTO;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import com.google.gson.Gson;
50 import com.google.gson.GsonBuilder;
51 import com.google.gson.JsonElement;
52 import com.google.gson.JsonParseException;
53 import com.google.gson.JsonParser;
54
55 /**
56  * Support creation of the miot db files
57  * based on the the online miot spec files
58  *
59  *
60  * @author Marcel Verpaalen - Initial contribution
61  */
62 @NonNullByDefault
63 public class MiotParser {
64     private final Logger logger = LoggerFactory.getLogger(MiotParser.class);
65
66     private static final String BASEURL = "https://miot-spec.org/miot-spec-v2/";
67     private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
68     private static final boolean SKIP_SIID_1 = true;
69     private static final boolean INCLUDE_MANUAL_ACTIONS_COMMENT = false;
70
71     private String model;
72     private @Nullable String urn;
73     private @Nullable JsonElement urnData;
74     private @Nullable MiIoBasicDevice device;
75
76     public MiotParser(String model) {
77         this.model = model;
78     }
79
80     public static MiotParser parse(String model, HttpClient httpClient) throws MiotParseException {
81         MiotParser miotParser = new MiotParser(model);
82         try {
83             String urn = miotParser.getURN(model, httpClient);
84             if (urn == null) {
85                 throw new MiotParseException("Device not found in in miot specs : " + model);
86             }
87             JsonElement urnData = miotParser.getUrnData(urn, httpClient);
88             miotParser.getDevice(urnData);
89             return miotParser;
90         } catch (Exception e) {
91             throw new MiotParseException("Error parsing miot data: " + e.getMessage(), e);
92         }
93     }
94
95     /**
96      * Outputs the device json file touched up so the format matches the regular OH standard formatting
97      *
98      * @param device
99      * @return
100      */
101     static public String toJson(MiIoBasicDevice device) {
102         String usersJson = GSON.toJson(device);
103         usersJson = usersJson.replace(".0,\n", ",\n");
104         usersJson = usersJson.replace("\n", "\r\n").replace("  ", "\t");
105         return usersJson;
106     }
107
108     public void writeDevice(String path, MiIoBasicDevice device) {
109         try (PrintWriter out = new PrintWriter(path)) {
110             out.println(toJson(device));
111             logger.info("Database file created:{}", path);
112         } catch (FileNotFoundException e) {
113             logger.info("Error writing file: {}", e.getMessage());
114         }
115     }
116
117     public MiIoBasicDevice getDevice(JsonElement urnData) throws MiotParseException {
118         Set<String> unknownUnits = new HashSet<>();
119         Map<ActionDTO, ServiceDTO> deviceActions = new LinkedHashMap<>();
120         StringBuilder channelConfigText = new StringBuilder("Suggested additional channelType \r\n");
121
122         StringBuilder actionText = new StringBuilder("Manual actions for execution\r\n");
123
124         MiIoBasicDevice device = new MiIoBasicDevice();
125         DeviceMapping deviceMapping = new DeviceMapping();
126         MiotDeviceDataDTO miotDevice = GSON.fromJson(urnData, MiotDeviceDataDTO.class);
127         if (miotDevice == null) {
128             throw new MiotParseException("Error parsing miot data: null");
129         }
130         List<MiIoBasicChannel> miIoBasicChannels = new ArrayList<>();
131         deviceMapping.setPropertyMethod(MiIoCommand.GET_PROPERTIES.getCommand());
132         deviceMapping.setMaxProperties(1);
133         deviceMapping.setExperimental(true);
134         deviceMapping.setId(Arrays.asList(new String[] { model }));
135         Set<String> propCheck = new HashSet<>();
136
137         for (ServiceDTO service : miotDevice.services) {
138             String serviceId = service.type.substring(service.type.indexOf("service:")).split(":")[1];
139             logger.info("SID: {}, description: {}, identifier: {}", service.siid, service.description, serviceId);
140
141             if (service.properties != null) {
142                 for (PropertyDTO property : service.properties) {
143                     String propertyId = property.type.substring(property.type.indexOf("property:")).split(":")[1];
144                     logger.info("siid: {}, description: {}, piid: {}, description: {}, identifier: {}", service.siid,
145                             service.description, property.piid, property.description, propertyId);
146                     if (service.siid == 1 && SKIP_SIID_1) {
147                         continue;
148                     }
149                     if (property.access.contains("read") || property.access.contains("write")) {
150                         MiIoBasicChannel miIoBasicChannel = new MiIoBasicChannel();
151                         miIoBasicChannel
152                                 .setFriendlyName((isPureAscii(service.description) && !service.description.isBlank()
153                                         ? captializedName(service.description)
154                                         : captializedName(serviceId))
155                                         + " - "
156                                         + (isPureAscii(property.description) && !property.description.isBlank()
157                                                 ? captializedName(property.description)
158                                                 : captializedName(propertyId)));
159                         miIoBasicChannel.setSiid(service.siid);
160                         miIoBasicChannel.setPiid(property.piid);
161                         // avoid duplicates and make camel case and avoid invalid channel names
162                         String chanId = propertyId.replace(" ", "").replace(".", "_").replace("-", "_");
163
164                         int cnt = 0;
165                         while (propCheck.contains(chanId + Integer.toString(cnt))) {
166                             cnt++;
167                         }
168                         propCheck.add(chanId.concat(Integer.toString(cnt)));
169                         if (cnt > 0) {
170                             chanId = chanId.concat(Integer.toString(cnt));
171                             propertyId = propertyId.concat(Integer.toString(cnt));
172                             logger.warn("duplicate for property:{} - {} ({}", chanId, property.description, cnt);
173                         }
174                         if (property.unit != null && !property.unit.isBlank()) {
175                             if (!property.unit.contains("none")) {
176                                 miIoBasicChannel.setUnit(property.unit);
177                             }
178                         }
179                         miIoBasicChannel.setProperty(propertyId);
180                         miIoBasicChannel.setChannel(chanId);
181                         switch (property.format) {
182                             case "bool":
183                                 miIoBasicChannel.setType("Switch");
184                                 break;
185                             case "uint8":
186                             case "uint16":
187                             case "uint32":
188                             case "int8":
189                             case "int16":
190                             case "int32":
191                             case "int64":
192                             case "float":
193                                 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
194                                 int decimals = -1;
195                                 String unit = "";
196                                 if (stateDescription == null) {
197                                     stateDescription = new StateDescriptionDTO();
198                                 }
199                                 String type = MiIoQuantiyTypesConversion.getType(property.unit);
200                                 if (type != null) {
201                                     miIoBasicChannel.setType("Number" + ":" + type);
202                                     unit = " %unit%";
203                                     decimals = property.format.contentEquals("float") ? 1 : 0;
204
205                                 } else {
206                                     miIoBasicChannel.setType("Number");
207                                     decimals = property.format.contentEquals("uint8") ? 0 : 1;
208                                     if (property.unit != null) {
209                                         unknownUnits.add(property.unit);
210                                     }
211                                 }
212                                 if (property.valueRange != null && property.valueRange.size() == 3) {
213                                     stateDescription
214                                             .setMinimum(BigDecimal.valueOf(property.valueRange.get(0).doubleValue()));
215                                     stateDescription
216                                             .setMaximum(BigDecimal.valueOf(property.valueRange.get(1).doubleValue()));
217
218                                     double step = property.valueRange.get(2).doubleValue();
219                                     if (step != 0) {
220                                         stateDescription.setStep(BigDecimal.valueOf(step));
221                                         if (step >= 1) {
222                                             decimals = 0;
223                                         }
224                                     }
225                                 }
226                                 if (decimals > -1) {
227                                     stateDescription.setPattern("%." + Integer.toString(decimals) + "f" + unit);
228                                 }
229                                 miIoBasicChannel.setStateDescription(stateDescription);
230                                 break;
231                             case "string":
232                                 miIoBasicChannel.setType("String");
233                                 break;
234                             case "hex":
235                                 miIoBasicChannel.setType("String");
236                                 logger.info("no type mapping implemented for {}", property.format);
237                                 break;
238                             default:
239                                 miIoBasicChannel.setType("String");
240                                 logger.info("no type mapping for {}", property.format);
241                                 break;
242                         }
243                         miIoBasicChannel.setRefresh(property.access.contains("read"));
244                         // add option values
245                         if (property.valueList != null && property.valueList.size() > 0) {
246                             StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
247                             if (stateDescription == null) {
248                                 stateDescription = new StateDescriptionDTO();
249                             }
250                             stateDescription.setPattern(null);
251                             List<OptionsValueListDTO> channeloptions = new LinkedList<>();
252                             for (OptionsValueDescriptionsListDTO miotOption : property.valueList) {
253                                 // miIoBasicChannel.setValueList(property.valueList);
254                                 OptionsValueListDTO basicOption = new OptionsValueListDTO();
255                                 basicOption.setLabel(miotOption.getDescription());
256                                 basicOption.setValue(String.valueOf(miotOption.value));
257                                 channeloptions.add(basicOption);
258                             }
259                             stateDescription.setOptions(channeloptions);
260                             miIoBasicChannel.setStateDescription(stateDescription);
261
262                             // Add the mapping for the readme
263                             StringBuilder mapping = new StringBuilder();
264                             mapping.append("Value mapping [");
265
266                             for (OptionsValueDescriptionsListDTO valueMap : property.valueList) {
267                                 mapping.append(String.format("\"%d\"=\"%s\",", valueMap.value, valueMap.description));
268                             }
269                             mapping.deleteCharAt(mapping.length() - 1);
270                             mapping.append("]");
271                             miIoBasicChannel.setReadmeComment(mapping.toString());
272                         }
273                         if (property.access.contains("write")) {
274                             List<MiIoDeviceAction> miIoDeviceActions = new ArrayList<>();
275                             MiIoDeviceAction action = new MiIoDeviceAction();
276                             action.setCommand("set_properties");
277                             switch (property.format) {
278                                 case "bool":
279                                     action.setparameterType(CommandParameterType.ONOFFBOOL);
280                                     break;
281                                 case "uint8":
282                                 case "int32":
283                                 case "float":
284                                     action.setparameterType(CommandParameterType.NUMBER);
285                                     break;
286                                 case "string":
287                                     action.setparameterType(CommandParameterType.STRING);
288
289                                     break;
290                                 default:
291                                     action.setparameterType(CommandParameterType.STRING);
292                                     break;
293                             }
294                             miIoDeviceActions.add(action);
295                             miIoBasicChannel.setActions(miIoDeviceActions);
296                         } else {
297                             StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
298                             if (stateDescription == null) {
299                                 stateDescription = new StateDescriptionDTO();
300                             }
301                             stateDescription.setReadOnly(true);
302                             miIoBasicChannel.setStateDescription(stateDescription);
303                         }
304                         miIoBasicChannels.add(miIoBasicChannel);
305                     } else {
306                         logger.info("No reading siid: {}, description: {}, piid: {},description: {}", service.siid,
307                                 service.description, property.piid, property.description);
308                     }
309                 }
310                 if (service.actions != null) {
311                     for (ActionDTO action : service.actions) {
312                         deviceActions.put(action, service);
313                         String actionId = action.type.substring(action.type.indexOf("action:")).split(":")[1];
314                         actionText.append("`action{");
315                         actionText.append(String.format("\"did\":\"%s-%s\",", serviceId, actionId));
316                         actionText.append(String.format("\"siid\":%d,", service.siid));
317                         actionText.append(String.format("\"aiid\":%d,", action.iid));
318                         actionText.append(String.format("\"in\":%s", action.in));
319                         actionText.append("}`\r\n");
320                     }
321
322                 }
323             } else {
324                 logger.info("SID: {}, description: {} has no identified properties", service.siid, service.description);
325             }
326         }
327         if (!deviceActions.isEmpty()) {
328             miIoBasicChannels.add(0, actionChannel(deviceActions));
329         }
330         deviceMapping.setChannels(miIoBasicChannels);
331         device.setDevice(deviceMapping);
332         if (actionText.length() > 35 && INCLUDE_MANUAL_ACTIONS_COMMENT) {
333             deviceMapping.setReadmeComment(
334                     "Identified " + actionText.toString().replace("Manual", "manual").replace("\r\n", "<br />")
335                             + "Please test and feedback if they are working so they can be linked to a channel.");
336         }
337         logger.info(channelConfigText.toString());
338         if (actionText.length() > 30) {
339             logger.info("{}", actionText);
340         } else {
341             logger.info("No actions defined for device");
342         }
343         unknownUnits.remove("none");
344         if (!unknownUnits.isEmpty()) {
345             logger.info("New units identified (inform developer): {}", String.join(", ", unknownUnits));
346         }
347
348         this.device = device;
349         return device;
350     }
351
352     private MiIoBasicChannel actionChannel(Map<ActionDTO, ServiceDTO> deviceActions) {
353         MiIoBasicChannel miIoBasicChannel = new MiIoBasicChannel();
354         if (!deviceActions.isEmpty()) {
355             miIoBasicChannel.setProperty("");
356             miIoBasicChannel.setChannel("actions");
357             miIoBasicChannel.setFriendlyName("Actions");
358             miIoBasicChannel.setType("String");
359             miIoBasicChannel.setRefresh(false);
360             StateDescriptionDTO stateDescription = new StateDescriptionDTO();
361             List<OptionsValueListDTO> options = new LinkedList<>();
362             List<MiIoDeviceAction> miIoDeviceActions = new LinkedList<>();
363             deviceActions.forEach((action, service) -> {
364                 String actionId = action.type.substring(action.type.indexOf("action:")).split(":")[1];
365                 String serviceId = service.type.substring(service.type.indexOf("service:")).split(":")[1];
366                 String description = String.format("%s-%s", serviceId, actionId);
367                 OptionsValueListDTO option = new OptionsValueListDTO();
368                 option.label = captializedName(description);
369                 option.value = description;
370                 options.add(option);
371                 MiIoDeviceAction miIoDeviceAction = new MiIoDeviceAction();
372                 miIoDeviceAction.setCommand("action");
373                 miIoDeviceAction.setparameterType(CommandParameterType.EMPTY);
374                 miIoDeviceAction.setSiid(service.siid);
375                 miIoDeviceAction.setAiid(action.iid);
376                 if (!action.in.isEmpty()) {
377                     miIoDeviceAction.setParameters(JsonParser.parseString(GSON.toJson(action.in)).getAsJsonArray());
378                     miIoDeviceAction.setparameterType("fromparameter");
379                 }
380                 MiIoDeviceActionCondition miIoDeviceActionCondition = new MiIoDeviceActionCondition();
381                 String json = String.format("[{ \"matchValue\"=\"%s\"}]", description);
382                 miIoDeviceActionCondition.setName("matchValue");
383                 miIoDeviceActionCondition.setParameters(JsonParser.parseString(json).getAsJsonArray());
384                 miIoDeviceAction.setCondition(miIoDeviceActionCondition);
385                 miIoDeviceActions.add(miIoDeviceAction);
386             });
387             stateDescription.setOptions(options);
388             miIoBasicChannel.setStateDescription(stateDescription);
389             miIoBasicChannel.setActions(miIoDeviceActions);
390         }
391         return miIoBasicChannel;
392     }
393
394     private static String captializedName(String name) {
395         if (name.isEmpty()) {
396             return name;
397         }
398         String str = name.replace("-", " ").replace(".", " ");
399         return Arrays.stream(str.split("\\s+")).map(t -> t.substring(0, 1).toUpperCase() + t.substring(1))
400                 .collect(Collectors.joining(" "));
401     }
402
403     public static boolean isPureAscii(String v) {
404         return StandardCharsets.US_ASCII.newEncoder().canEncode(v);
405     }
406
407     private JsonElement getUrnData(String urn, HttpClient httpClient)
408             throws InterruptedException, TimeoutException, ExecutionException, JsonParseException {
409         ContentResponse response;
410         String urlStr = BASEURL + "instance?type=" + urn;
411         logger.info("miot info: {}", urlStr);
412         response = httpClient.newRequest(urlStr).timeout(15, TimeUnit.SECONDS).send();
413         JsonElement json = JsonParser.parseString(response.getContentAsString());
414         this.urnData = json;
415         return json;
416     }
417
418     private @Nullable String getURN(String model, HttpClient httpClient) {
419         ContentResponse response;
420         try {
421             response = httpClient.newRequest(BASEURL + "instances?status=released").timeout(15, TimeUnit.SECONDS)
422                     .send();
423             JsonElement json = JsonParser.parseString(response.getContentAsString());
424             UrnsDTO data = GSON.fromJson(json, UrnsDTO.class);
425             for (ModelUrnsDTO device : data.getInstances()) {
426                 if (device.getModel().contentEquals(model)) {
427                     this.urn = device.getType();
428                     return device.getType();
429                 }
430             }
431         } catch (InterruptedException | TimeoutException | ExecutionException e) {
432             logger.debug("Failed downloading models: {}", e.getMessage());
433         } catch (JsonParseException e) {
434             logger.debug("Failed parsing downloading models: {}", e.getMessage());
435         }
436
437         return null;
438     }
439
440     public String getModel() {
441         return model;
442     }
443
444     public @Nullable String getUrn() {
445         return urn;
446     }
447
448     public @Nullable JsonElement getUrnData() {
449         return urnData;
450     }
451
452     public @Nullable MiIoBasicDevice getDevice() {
453         return device;
454     }
455 }