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