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
14 package org.openhab.binding.miio.internal.miot;
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;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31 import java.util.stream.Collectors;
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;
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;
56 * Support creation of the miot db files
57 * based on the the online miot spec files
60 * @author Marcel Verpaalen - Initial contribution
63 public class MiotParser {
64 private final Logger logger = LoggerFactory.getLogger(MiotParser.class);
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;
71 private @Nullable String urn;
72 private @Nullable JsonElement urnData;
73 private @Nullable MiIoBasicDevice device;
75 public MiotParser(String model) {
79 public static MiotParser parse(String model, HttpClient httpClient) throws MiotParseException {
80 MiotParser miotParser = new MiotParser(model);
82 String urn = miotParser.getURN(model, httpClient);
84 throw new MiotParseException("Device not found in in miot specs : " + model);
86 JsonElement urnData = miotParser.getUrnData(urn, httpClient);
87 miotParser.getDevice(urnData);
89 } catch (Exception e) {
90 throw new MiotParseException("Error parsing miot data: " + e.getMessage(), e);
95 * Outputs the device json file touched up so the format matches the regular OH standard formatting
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");
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());
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");
121 StringBuilder actionText = new StringBuilder("Manual actions for execution\r\n");
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");
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<>();
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);
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) {
148 if (property.access.contains("read") || property.access.contains("write")) {
149 MiIoBasicChannel miIoBasicChannel = new MiIoBasicChannel();
151 .setFriendlyName((isPureAscii(service.description) && !service.description.isBlank()
152 ? captializedName(service.description)
153 : captializedName(serviceId))
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("-", "_");
164 while (propCheck.contains(chanId + Integer.toString(cnt))) {
167 propCheck.add(chanId.concat(Integer.toString(cnt)));
169 chanId = chanId.concat(Integer.toString(cnt));
170 propertyId = propertyId.concat(Integer.toString(cnt));
171 logger.warn("duplicate for property:{} - {} ({}", chanId, property.description, cnt);
173 if (property.unit != null && !property.unit.isBlank()) {
174 if (!property.unit.contains("none")) {
175 miIoBasicChannel.setUnit(property.unit);
178 miIoBasicChannel.setProperty(propertyId);
179 miIoBasicChannel.setChannel(chanId);
180 switch (property.format) {
182 miIoBasicChannel.setType("Switch");
192 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
195 if (stateDescription == null) {
196 stateDescription = new StateDescriptionDTO();
198 String type = MiIoQuantiyTypesConversion.getType(property.unit);
200 miIoBasicChannel.setType("Number" + ":" + type);
202 decimals = property.format.contentEquals("float") ? 1 : 0;
205 miIoBasicChannel.setType("Number");
206 decimals = property.format.contentEquals("uint8") ? 0 : 1;
207 if (property.unit != null) {
208 unknownUnits.add(property.unit);
211 if (property.valueRange != null && property.valueRange.size() == 3) {
213 .setMinimum(BigDecimal.valueOf(property.valueRange.get(0).doubleValue()));
215 .setMaximum(BigDecimal.valueOf(property.valueRange.get(1).doubleValue()));
217 double step = property.valueRange.get(2).doubleValue();
219 stateDescription.setStep(BigDecimal.valueOf(step));
226 stateDescription.setPattern("%." + Integer.toString(decimals) + "f" + unit);
228 miIoBasicChannel.setStateDescription(stateDescription);
231 miIoBasicChannel.setType("String");
234 miIoBasicChannel.setType("String");
235 logger.info("no type mapping implemented for {}", property.format);
238 miIoBasicChannel.setType("String");
239 logger.info("no type mapping for {}", property.format);
242 miIoBasicChannel.setRefresh(property.access.contains("read"));
244 if (property.valueList != null && property.valueList.size() > 0) {
245 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
246 if (stateDescription == null) {
247 stateDescription = new StateDescriptionDTO();
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);
258 stateDescription.setOptions(channeloptions);
259 miIoBasicChannel.setStateDescription(stateDescription);
261 // Add the mapping for the readme
262 StringBuilder mapping = new StringBuilder();
263 mapping.append("Value mapping [");
265 for (OptionsValueDescriptionsListDTO valueMap : property.valueList) {
266 mapping.append(String.format("\"%d\"=\"%s\",", valueMap.value, valueMap.description));
268 mapping.deleteCharAt(mapping.length() - 1);
270 miIoBasicChannel.setReadmeComment(mapping.toString());
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) {
278 action.setparameterType(CommandParameterType.ONOFFBOOL);
283 action.setparameterType(CommandParameterType.NUMBER);
286 action.setparameterType(CommandParameterType.STRING);
290 action.setparameterType(CommandParameterType.STRING);
293 miIoDeviceActions.add(action);
294 miIoBasicChannel.setActions(miIoDeviceActions);
296 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
297 if (stateDescription == null) {
298 stateDescription = new StateDescriptionDTO();
300 stateDescription.setReadOnly(true);
301 miIoBasicChannel.setStateDescription(stateDescription);
303 miIoBasicChannels.add(miIoBasicChannel);
305 logger.info("No reading siid: {}, description: {}, piid: {},description: {}", service.siid,
306 service.description, property.piid, property.description);
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");
323 logger.info("SID: {}, description: {} has no identified properties", service.siid, service.description);
326 if (!deviceActions.isEmpty()) {
327 miIoBasicChannels.add(0, actionChannel(deviceActions));
329 deviceMapping.setChannels(miIoBasicChannels);
330 device.setDevice(deviceMapping);
331 logger.info(channelConfigText.toString());
332 if (actionText.length() > 30) {
333 logger.info("{}", actionText);
335 logger.info("No actions defined for device");
337 unknownUnits.remove("none");
338 if (!unknownUnits.isEmpty()) {
339 logger.info("New units identified (inform developer): {}", String.join(", ", unknownUnits));
342 this.device = device;
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;
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");
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);
381 stateDescription.setOptions(options);
382 miIoBasicChannel.setStateDescription(stateDescription);
383 miIoBasicChannel.setActions(miIoDeviceActions);
385 return miIoBasicChannel;
388 private static String captializedName(String name) {
389 if (name.isEmpty()) {
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(" "));
397 public static boolean isPureAscii(String v) {
398 return StandardCharsets.US_ASCII.newEncoder().canEncode(v);
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());
412 private @Nullable String getURN(String model, HttpClient httpClient) {
413 ContentResponse response;
415 response = httpClient.newRequest(BASEURL + "instances?status=released").timeout(15, TimeUnit.SECONDS)
417 JsonElement json = JsonParser.parseString(response.getContentAsString());
418 UrnsDTO data = GSON.fromJson(json, UrnsDTO.class);
422 for (ModelUrnsDTO device : data.getInstances()) {
423 if (device.getModel().contentEquals(model)) {
424 this.urn = device.getType();
425 return device.getType();
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());
436 public String getModel() {
440 public @Nullable String getUrn() {
444 public @Nullable JsonElement getUrnData() {
448 public @Nullable MiIoBasicDevice getDevice() {