2 * Copyright (c) 2010-2022 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;
69 private static final boolean INCLUDE_MANUAL_ACTIONS_COMMENT = false;
72 private @Nullable String urn;
73 private @Nullable JsonElement urnData;
74 private @Nullable MiIoBasicDevice device;
76 public MiotParser(String model) {
80 public static MiotParser parse(String model, HttpClient httpClient) throws MiotParseException {
81 MiotParser miotParser = new MiotParser(model);
83 String urn = miotParser.getURN(model, httpClient);
85 throw new MiotParseException("Device not found in in miot specs : " + model);
87 JsonElement urnData = miotParser.getUrnData(urn, httpClient);
88 miotParser.getDevice(urnData);
90 } catch (Exception e) {
91 throw new MiotParseException("Error parsing miot data: " + e.getMessage(), e);
96 * Outputs the device json file touched up so the format matches the regular OH standard formatting
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");
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());
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");
122 StringBuilder actionText = new StringBuilder("Manual actions for execution\r\n");
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");
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<>();
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);
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) {
149 if (property.access.contains("read") || property.access.contains("write")) {
150 MiIoBasicChannel miIoBasicChannel = new MiIoBasicChannel();
152 .setFriendlyName((isPureAscii(service.description) && !service.description.isBlank()
153 ? captializedName(service.description)
154 : captializedName(serviceId))
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("-", "_");
165 while (propCheck.contains(chanId + Integer.toString(cnt))) {
168 propCheck.add(chanId.concat(Integer.toString(cnt)));
170 chanId = chanId.concat(Integer.toString(cnt));
171 propertyId = propertyId.concat(Integer.toString(cnt));
172 logger.warn("duplicate for property:{} - {} ({}", chanId, property.description, cnt);
174 if (property.unit != null && !property.unit.isBlank()) {
175 if (!property.unit.contains("none")) {
176 miIoBasicChannel.setUnit(property.unit);
179 miIoBasicChannel.setProperty(propertyId);
180 miIoBasicChannel.setChannel(chanId);
181 switch (property.format) {
183 miIoBasicChannel.setType("Switch");
193 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
196 if (stateDescription == null) {
197 stateDescription = new StateDescriptionDTO();
199 String type = MiIoQuantiyTypesConversion.getType(property.unit);
201 miIoBasicChannel.setType("Number" + ":" + type);
203 decimals = property.format.contentEquals("float") ? 1 : 0;
206 miIoBasicChannel.setType("Number");
207 decimals = property.format.contentEquals("uint8") ? 0 : 1;
208 if (property.unit != null) {
209 unknownUnits.add(property.unit);
212 if (property.valueRange != null && property.valueRange.size() == 3) {
214 .setMinimum(BigDecimal.valueOf(property.valueRange.get(0).doubleValue()));
216 .setMaximum(BigDecimal.valueOf(property.valueRange.get(1).doubleValue()));
218 double step = property.valueRange.get(2).doubleValue();
220 stateDescription.setStep(BigDecimal.valueOf(step));
227 stateDescription.setPattern("%." + Integer.toString(decimals) + "f" + unit);
229 miIoBasicChannel.setStateDescription(stateDescription);
232 miIoBasicChannel.setType("String");
235 miIoBasicChannel.setType("String");
236 logger.info("no type mapping implemented for {}", property.format);
239 miIoBasicChannel.setType("String");
240 logger.info("no type mapping for {}", property.format);
243 miIoBasicChannel.setRefresh(property.access.contains("read"));
245 if (property.valueList != null && property.valueList.size() > 0) {
246 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
247 if (stateDescription == null) {
248 stateDescription = new StateDescriptionDTO();
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);
259 stateDescription.setOptions(channeloptions);
260 miIoBasicChannel.setStateDescription(stateDescription);
262 // Add the mapping for the readme
263 StringBuilder mapping = new StringBuilder();
264 mapping.append("Value mapping [");
266 for (OptionsValueDescriptionsListDTO valueMap : property.valueList) {
267 mapping.append(String.format("\"%d\"=\"%s\",", valueMap.value, valueMap.description));
269 mapping.deleteCharAt(mapping.length() - 1);
271 miIoBasicChannel.setReadmeComment(mapping.toString());
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) {
279 action.setparameterType(CommandParameterType.ONOFFBOOL);
284 action.setparameterType(CommandParameterType.NUMBER);
287 action.setparameterType(CommandParameterType.STRING);
291 action.setparameterType(CommandParameterType.STRING);
294 miIoDeviceActions.add(action);
295 miIoBasicChannel.setActions(miIoDeviceActions);
297 StateDescriptionDTO stateDescription = miIoBasicChannel.getStateDescription();
298 if (stateDescription == null) {
299 stateDescription = new StateDescriptionDTO();
301 stateDescription.setReadOnly(true);
302 miIoBasicChannel.setStateDescription(stateDescription);
304 miIoBasicChannels.add(miIoBasicChannel);
306 logger.info("No reading siid: {}, description: {}, piid: {},description: {}", service.siid,
307 service.description, property.piid, property.description);
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");
324 logger.info("SID: {}, description: {} has no identified properties", service.siid, service.description);
327 if (!deviceActions.isEmpty()) {
328 miIoBasicChannels.add(0, actionChannel(deviceActions));
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.");
337 logger.info(channelConfigText.toString());
338 if (actionText.length() > 30) {
339 logger.info("{}", actionText);
341 logger.info("No actions defined for device");
343 unknownUnits.remove("none");
344 if (!unknownUnits.isEmpty()) {
345 logger.info("New units identified (inform developer): {}", String.join(", ", unknownUnits));
348 this.device = device;
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;
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");
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);
387 stateDescription.setOptions(options);
388 miIoBasicChannel.setStateDescription(stateDescription);
389 miIoBasicChannel.setActions(miIoDeviceActions);
391 return miIoBasicChannel;
394 private static String captializedName(String name) {
395 if (name.isEmpty()) {
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(" "));
403 public static boolean isPureAscii(String v) {
404 return StandardCharsets.US_ASCII.newEncoder().canEncode(v);
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());
418 private @Nullable String getURN(String model, HttpClient httpClient) {
419 ContentResponse response;
421 response = httpClient.newRequest(BASEURL + "instances?status=released").timeout(15, TimeUnit.SECONDS)
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();
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());
440 public String getModel() {
444 public @Nullable String getUrn() {
448 public @Nullable JsonElement getUrnData() {
452 public @Nullable MiIoBasicDevice getDevice() {