2 * Copyright (c) 2010-2020 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.*;
17 import java.awt.Color;
18 import java.io.IOException;
20 import java.util.ArrayList;
21 import java.util.HashMap;
22 import java.util.List;
24 import java.util.concurrent.TimeUnit;
26 import javax.measure.Unit;
27 import javax.measure.format.ParserException;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
32 import org.openhab.binding.miio.internal.MiIoCommand;
33 import org.openhab.binding.miio.internal.MiIoCryptoException;
34 import org.openhab.binding.miio.internal.MiIoQuantiyTypes;
35 import org.openhab.binding.miio.internal.MiIoSendCommand;
36 import org.openhab.binding.miio.internal.Utils;
37 import org.openhab.binding.miio.internal.basic.ActionConditions;
38 import org.openhab.binding.miio.internal.basic.CommandParameterType;
39 import org.openhab.binding.miio.internal.basic.Conversions;
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.MiIoDatabaseWatchService;
43 import org.openhab.binding.miio.internal.basic.MiIoDeviceAction;
44 import org.openhab.binding.miio.internal.basic.MiIoDeviceActionCondition;
45 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
46 import org.openhab.core.cache.ExpiringCache;
47 import org.openhab.core.library.types.DecimalType;
48 import org.openhab.core.library.types.HSBType;
49 import org.openhab.core.library.types.OnOffType;
50 import org.openhab.core.library.types.PercentType;
51 import org.openhab.core.library.types.QuantityType;
52 import org.openhab.core.library.types.StringType;
53 import org.openhab.core.library.unit.SIUnits;
54 import org.openhab.core.library.unit.SmartHomeUnits;
55 import org.openhab.core.thing.Channel;
56 import org.openhab.core.thing.ChannelUID;
57 import org.openhab.core.thing.Thing;
58 import org.openhab.core.thing.binding.builder.ChannelBuilder;
59 import org.openhab.core.thing.binding.builder.ThingBuilder;
60 import org.openhab.core.thing.type.ChannelTypeRegistry;
61 import org.openhab.core.thing.type.ChannelTypeUID;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.slf4j.Logger;
65 import org.slf4j.LoggerFactory;
67 import com.google.gson.Gson;
68 import com.google.gson.GsonBuilder;
69 import com.google.gson.JsonArray;
70 import com.google.gson.JsonElement;
71 import com.google.gson.JsonIOException;
72 import com.google.gson.JsonObject;
73 import com.google.gson.JsonPrimitive;
74 import com.google.gson.JsonSyntaxException;
77 * The {@link MiIoBasicHandler} is responsible for handling commands, which are
78 * sent to one of the channels.
80 * @author Marcel Verpaalen - Initial contribution
83 public class MiIoBasicHandler extends MiIoAbstractHandler {
84 private final Logger logger = LoggerFactory.getLogger(MiIoBasicHandler.class);
85 private boolean hasChannelStructure;
87 private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
88 scheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
92 List<MiIoBasicChannel> refreshList = new ArrayList<>();
93 private Map<String, MiIoBasicChannel> refreshListCustomCommands = new HashMap<>();
95 private @Nullable MiIoBasicDevice miioDevice;
96 private Map<ChannelUID, MiIoBasicChannel> actions = new HashMap<>();
97 private ChannelTypeRegistry channelTypeRegistry;
99 public MiIoBasicHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
100 ChannelTypeRegistry channelTypeRegistry) {
101 super(thing, miIoDatabaseWatchService);
102 this.channelTypeRegistry = channelTypeRegistry;
106 public void initialize() {
108 hasChannelStructure = false;
109 isIdentified = false;
110 refreshList = new ArrayList<>();
111 refreshListCustomCommands = new HashMap<>();
115 public void handleCommand(ChannelUID channelUID, Command receivedCommand) {
116 Command command = receivedCommand;
117 if (command == RefreshType.REFRESH) {
118 if (updateDataCache.isExpired()) {
119 logger.debug("Refreshing {}", channelUID);
120 updateDataCache.getValue();
122 logger.debug("Refresh {} skipped. Already refreshing", channelUID);
126 if (channelUID.getId().equals(CHANNEL_COMMAND)) {
127 cmds.put(sendCommand(command.toString()), command.toString());
130 logger.debug("Locating action for {} channel '{}': '{}'", getThing().getUID(), channelUID.getId(), command);
131 if (!actions.isEmpty()) {
132 MiIoBasicChannel miIoBasicChannel = actions.get(channelUID);
133 if (miIoBasicChannel != null) {
135 for (MiIoDeviceAction action : miIoBasicChannel.getActions()) {
137 JsonElement value = null;
138 JsonArray parameters = action.getParameters().deepCopy();
139 for (int i = 0; i < action.getParameters().size(); i++) {
140 JsonElement p = action.getParameters().get(i);
141 if (p.isJsonPrimitive() && p.getAsString().toLowerCase().contains("$value$")) {
146 String cmd = action.getCommand();
147 CommandParameterType paramType = action.getparameterType();
148 if (command instanceof QuantityType) {
149 QuantityType<?> qtc = null;
151 if (!miIoBasicChannel.getUnit().isBlank()) {
152 Unit<?> unit = MiIoQuantiyTypes.get(miIoBasicChannel.getUnit());
154 qtc = ((QuantityType<?>) command).toUnit(unit);
157 } catch (ParserException e) {
161 command = new DecimalType(qtc.toBigDecimal());
163 logger.debug("Could not convert QuantityType to '{}'", miIoBasicChannel.getUnit());
164 command = new DecimalType(((QuantityType<?>) command).toBigDecimal());
167 if (paramType == CommandParameterType.COLOR) {
168 if (command instanceof HSBType) {
169 HSBType hsb = (HSBType) command;
170 Color color = Color.getHSBColor(hsb.getHue().floatValue() / 360,
171 hsb.getSaturation().floatValue() / 100, hsb.getBrightness().floatValue() / 100);
172 value = new JsonPrimitive(
173 (color.getRed() << 16) + (color.getGreen() << 8) + color.getBlue());
174 } else if (command instanceof DecimalType) {
175 // actually brightness is being set instead of a color
176 value = new JsonPrimitive(((DecimalType) command).toBigDecimal());
177 } else if (command instanceof OnOffType) {
178 value = new JsonPrimitive(command == OnOffType.ON ? 100 : 0);
180 logger.debug("Unsupported command for COLOR: {}", command);
182 } else if (command instanceof OnOffType) {
183 if (paramType == CommandParameterType.ONOFF) {
184 value = new JsonPrimitive(command == OnOffType.ON ? "on" : "off");
185 } else if (paramType == CommandParameterType.ONOFFPARA) {
186 cmd = cmd.replace("*", command == OnOffType.ON ? "on" : "off");
187 value = new JsonArray();
188 } else if (paramType == CommandParameterType.ONOFFBOOL) {
189 boolean boolCommand = command == OnOffType.ON;
190 value = new JsonPrimitive(boolCommand);
191 } else if (paramType == CommandParameterType.ONOFFBOOLSTRING) {
192 value = new JsonPrimitive(command == OnOffType.ON ? "true" : "false");
194 } else if (command instanceof DecimalType) {
195 value = new JsonPrimitive(((DecimalType) command).toBigDecimal());
196 } else if (command instanceof StringType) {
197 if (paramType == CommandParameterType.STRING) {
198 value = new JsonPrimitive(command.toString().toLowerCase());
199 } else if (paramType == CommandParameterType.CUSTOMSTRING) {
200 value = new JsonPrimitive(parameters.get(valuePos).getAsString().replace("$value",
201 command.toString().toLowerCase()));
204 value = new JsonPrimitive(command.toString().toLowerCase());
206 if (paramType == CommandParameterType.EMPTY) {
207 value = new JsonArray();
209 final MiIoDeviceActionCondition miIoDeviceActionCondition = action.getCondition();
210 if (miIoDeviceActionCondition != null) {
211 value = ActionConditions.executeAction(miIoDeviceActionCondition, deviceVariables, value,
214 // Check for miot channel
216 if (action.isMiOtAction()) {
217 value = miotActionTransform(action, miIoBasicChannel, value);
218 } else if (miIoBasicChannel.isMiOt()) {
219 value = miotTransform(miIoBasicChannel, value);
222 if (paramType != CommandParameterType.NONE && paramType != CommandParameterType.ONOFFPARA
224 if (parameters.size() > 0) {
225 parameters.set(valuePos, value);
227 parameters.add(value);
230 if (action.isMiOtAction() && parameters.size() > 0 && parameters.get(0).isJsonObject()) {
231 // hack as unlike any other commands miot actions parameters appear to be send as a json object
232 // instead of a json array
233 cmd = cmd + parameters.get(0).getAsJsonObject().toString();
235 cmd = cmd + parameters.toString();
238 logger.debug("Sending command {}", cmd);
241 if (miIoDeviceActionCondition != null) {
242 logger.debug("Conditional command {} not send, condition '{}' not met", cmd,
243 miIoDeviceActionCondition.getName());
245 logger.debug("Command not send. Value null");
250 logger.debug("Channel Id {} not in mapping.", channelUID.getId());
251 if (logger.isTraceEnabled()) {
252 for (ChannelUID a : actions.keySet()) {
253 logger.trace("Available entries: {} : {}", a, actions.get(a).getFriendlyName());
257 updateDataCache.invalidateValue();
258 scheduler.schedule(() -> {
260 }, 3000, TimeUnit.MILLISECONDS);
262 logger.debug("Actions not loaded yet");
266 private @Nullable JsonElement miotTransform(MiIoBasicChannel miIoBasicChannel, @Nullable JsonElement value) {
267 JsonObject json = new JsonObject();
268 json.addProperty("did", miIoBasicChannel.getChannel());
269 json.addProperty("siid", miIoBasicChannel.getSiid());
270 json.addProperty("piid", miIoBasicChannel.getPiid());
271 json.add("value", value);
275 private @Nullable JsonElement miotActionTransform(MiIoDeviceAction action, MiIoBasicChannel miIoBasicChannel,
276 @Nullable JsonElement value) {
277 JsonObject json = new JsonObject();
278 json.addProperty("did", miIoBasicChannel.getChannel());
279 json.addProperty("siid", action.getSiid());
280 json.addProperty("aiid", action.getAiid());
282 json.add("in", value);
288 protected synchronized void updateData() {
289 logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
290 final MiIoAsyncCommunication miioCom = getConnection();
292 if (!hasConnection() || skipUpdate() || miioCom == null) {
295 checkChannelStructure();
297 miioCom.queueCommand(MiIoCommand.MIIO_INFO);
299 final MiIoBasicDevice midevice = miioDevice;
300 if (midevice != null) {
301 refreshProperties(midevice);
302 refreshCustomProperties(midevice);
305 } catch (Exception e) {
306 logger.debug("Error while updating '{}': ", getThing().getUID().toString(), e);
310 private void refreshCustomProperties(MiIoBasicDevice midevice) {
311 for (MiIoBasicChannel miChannel : refreshListCustomCommands.values()) {
312 sendCommand(miChannel.getChannelCustomRefreshCommand());
316 private boolean refreshProperties(MiIoBasicDevice device) {
317 MiIoCommand command = MiIoCommand.getCommand(device.getDevice().getPropertyMethod());
318 int maxProperties = device.getDevice().getMaxProperties();
319 JsonArray getPropString = new JsonArray();
320 for (MiIoBasicChannel miChannel : refreshList) {
321 JsonElement property;
322 if (miChannel.isMiOt()) {
323 JsonObject json = new JsonObject();
324 json.addProperty("did", miChannel.getProperty());
325 json.addProperty("siid", miChannel.getSiid());
326 json.addProperty("piid", miChannel.getPiid());
329 property = new JsonPrimitive(miChannel.getProperty());
331 getPropString.add(property);
332 if (getPropString.size() >= maxProperties) {
333 sendRefreshProperties(command, getPropString);
334 getPropString = new JsonArray();
337 if (getPropString.size() > 0) {
338 sendRefreshProperties(command, getPropString);
343 private void sendRefreshProperties(MiIoCommand command, JsonArray getPropString) {
345 final MiIoAsyncCommunication miioCom = this.miioCom;
346 if (miioCom != null) {
347 miioCom.queueCommand(command, getPropString.toString());
349 } catch (MiIoCryptoException | IOException e) {
350 logger.debug("Send refresh failed {}", e.getMessage(), e);
355 * Checks if the channel structure has been build already based on the model data. If not build it.
357 private void checkChannelStructure() {
358 final MiIoBindingConfiguration configuration = this.configuration;
359 if (configuration == null) {
362 if (!hasChannelStructure) {
363 if (configuration.model == null || configuration.model.isEmpty()) {
364 logger.debug("Model needs to be determined");
365 isIdentified = false;
367 hasChannelStructure = buildChannelStructure(configuration.model);
370 if (hasChannelStructure) {
371 refreshList = new ArrayList<>();
372 final MiIoBasicDevice miioDevice = this.miioDevice;
373 if (miioDevice != null) {
374 for (MiIoBasicChannel miChannel : miioDevice.getDevice().getChannels()) {
375 if (miChannel.getRefresh()) {
376 if (miChannel.getChannelCustomRefreshCommand().isBlank()) {
377 refreshList.add(miChannel);
379 String i = miChannel.getChannelCustomRefreshCommand().split("\\[")[0];
380 refreshListCustomCommands.put(i.trim(), miChannel);
389 private boolean buildChannelStructure(String deviceName) {
390 logger.debug("Building Channel Structure for {} - Model: {}", getThing().getUID().toString(), deviceName);
391 URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
393 logger.warn("Database entry for model '{}' cannot be found.", deviceName);
397 JsonObject deviceMapping = Utils.convertFileToJSON(fn);
398 logger.debug("Using device database: {} for device {}", fn.getFile(), deviceName);
399 Gson gson = new GsonBuilder().serializeNulls().create();
400 miioDevice = gson.fromJson(deviceMapping, MiIoBasicDevice.class);
401 for (Channel ch : getThing().getChannels()) {
402 logger.debug("Current thing channels {}, type: {}", ch.getUID(), ch.getChannelTypeUID());
404 ThingBuilder thingBuilder = editThing();
405 int channelsAdded = 0;
407 // make a map of the actions
408 actions = new HashMap<>();
409 final MiIoBasicDevice device = this.miioDevice;
410 if (device != null) {
411 for (MiIoBasicChannel miChannel : device.getDevice().getChannels()) {
412 logger.debug("properties {}", miChannel);
413 if (!miChannel.getType().isEmpty()) {
414 ChannelUID channelUID = addChannel(thingBuilder, miChannel.getChannel(),
415 miChannel.getChannelType(), miChannel.getType(), miChannel.getFriendlyName());
416 if (channelUID != null) {
417 actions.put(channelUID, miChannel);
420 logger.debug("Channel for {} ({}) not loaded", miChannel.getChannel(),
421 miChannel.getFriendlyName());
424 logger.debug("Channel {} ({}), not loaded, missing type", miChannel.getChannel(),
425 miChannel.getFriendlyName());
429 // only update if channels were added/removed
430 if (channelsAdded > 0) {
431 logger.debug("Current thing channels added: {}", channelsAdded);
432 updateThing(thingBuilder.build());
435 } catch (JsonIOException | JsonSyntaxException e) {
436 logger.warn("Error parsing database Json", e);
437 } catch (IOException e) {
438 logger.warn("Error reading database file", e);
439 } catch (Exception e) {
440 logger.warn("Error creating channel structure", e);
445 private @Nullable ChannelUID addChannel(ThingBuilder thingBuilder, @Nullable String channel, String channelType,
446 @Nullable String datatype, String friendlyName) {
447 if (channel == null || channel.isEmpty() || datatype == null || datatype.isEmpty()) {
448 logger.info("Channel '{}', UID '{}' cannot be added incorrectly configured database. ", channel,
449 getThing().getUID());
452 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channel);
454 // TODO: Need to understand if this harms anything. If yes, channel only to be added when not there already.
455 // current way allows to have no issues when channels are changing.
456 if (getThing().getChannel(channel) != null) {
457 logger.info("Channel '{}' for thing {} already exist... removing", channel, getThing().getUID());
458 thingBuilder.withoutChannel(new ChannelUID(getThing().getUID(), channel));
460 ChannelBuilder newChannel = ChannelBuilder.create(channelUID, datatype).withLabel(friendlyName);
461 boolean useGenericChannelType = false;
462 if (!channelType.isBlank()) {
463 ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
464 if (channelTypeRegistry.getChannelType(channelTypeUID) != null) {
465 newChannel = newChannel.withType(channelTypeUID);
467 logger.debug("ChannelType '{}' is not available. Check the Json file for {}", channelTypeUID,
468 getThing().getUID());
469 useGenericChannelType = true;
472 useGenericChannelType = true;
474 if (useGenericChannelType) {
475 newChannel = newChannel.withType(new ChannelTypeUID(BINDING_ID, datatype.toLowerCase()));
477 thingBuilder.withChannel(newChannel.build());
481 private @Nullable MiIoBasicChannel getChannel(String parameter) {
482 for (MiIoBasicChannel refreshEntry : refreshList) {
483 if (refreshEntry.getProperty().equals(parameter)) {
487 logger.trace("Did not find channel for {} in {}", parameter, refreshList);
491 private void updatePropsFromJsonArray(MiIoSendCommand response) {
492 JsonArray res = response.getResult().getAsJsonArray();
493 JsonArray para = parser.parse(response.getCommandString()).getAsJsonObject().get("params").getAsJsonArray();
494 if (res.size() != para.size()) {
495 logger.debug("Unexpected size different. Request size {}, response size {}. (Req: {}, Resp:{})",
496 para.size(), res.size(), para, res);
499 for (int i = 0; i < para.size(); i++) {
500 // This is a miot parameter
502 final JsonElement paraElement = para.get(i);
503 if (paraElement.isJsonObject()) { // miot channel
504 param = paraElement.getAsJsonObject().get("did").getAsString();
506 param = paraElement.getAsString();
508 JsonElement val = res.get(i);
509 if (val.isJsonNull()) {
510 logger.debug("Property '{}' returned null (is it supported?).", param);
512 } else if (val.isJsonObject()) { // miot channel
513 val = val.getAsJsonObject().get("value");
515 MiIoBasicChannel basicChannel = getChannel(param);
516 updateChannel(basicChannel, param, val);
520 private void updatePropsFromJsonObject(MiIoSendCommand response) {
521 JsonObject res = response.getResult().getAsJsonObject();
522 for (Object k : res.keySet()) {
523 String param = (String) k;
524 JsonElement val = res.get(param);
525 if (val.isJsonNull()) {
526 logger.debug("Property '{}' returned null (is it supported?).", param);
529 MiIoBasicChannel basicChannel = getChannel(param);
530 updateChannel(basicChannel, param, val);
534 private void updateChannel(@Nullable MiIoBasicChannel basicChannel, String param, JsonElement value) {
535 JsonElement val = value;
536 if (basicChannel == null) {
537 logger.debug("Channel not found for {}", param);
540 final String transformation = basicChannel.getTransfortmation();
541 if (transformation != null) {
542 JsonElement transformed = Conversions.execute(transformation, val);
543 logger.debug("Transformed with '{}': {} {} -> {} ", transformation, basicChannel.getFriendlyName(), val,
548 String[] chType = basicChannel.getType().toLowerCase().split(":");
551 quantityTypeUpdate(basicChannel, val, chType.length > 1 ? chType[1] : "");
554 updateState(basicChannel.getChannel(), new PercentType(val.getAsBigDecimal()));
557 updateState(basicChannel.getChannel(), new StringType(val.getAsString()));
560 updateState(basicChannel.getChannel(), val.getAsString().toLowerCase().equals("on")
561 || val.getAsString().toLowerCase().equals("true") ? OnOffType.ON : OnOffType.OFF);
564 Color rgb = new Color(val.getAsInt());
565 HSBType hsb = HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue());
566 updateState(basicChannel.getChannel(), hsb);
569 logger.debug("No update logic for channeltype '{}' ", basicChannel.getType());
571 } catch (Exception e) {
572 logger.debug("Error updating {} property {} with '{}' : {}: {}", getThing().getUID(),
573 basicChannel.getChannel(), val, e.getClass().getCanonicalName(), e.getMessage());
574 logger.trace("Property update error detail:", e);
578 private void quantityTypeUpdate(MiIoBasicChannel basicChannel, JsonElement val, String type) {
579 if (!basicChannel.getUnit().isBlank()) {
580 Unit<?> unit = MiIoQuantiyTypes.get(basicChannel.getUnit());
582 logger.debug("'{}' channel '{}' has unit '{}' with symbol '{}'.", getThing().getUID(),
583 basicChannel.getChannel(), basicChannel.getUnit(), unit);
584 updateState(basicChannel.getChannel(), new QuantityType<>(val.getAsBigDecimal(), unit));
586 logger.debug("Unit '{}' used by '{}' channel '{}' is not found.. using default unit.",
587 getThing().getUID(), basicChannel.getUnit(), basicChannel.getChannel());
590 // if no unit is provided or unit not found use default units, these units have so far been seen for miio
592 switch (type.toLowerCase()) {
594 updateState(basicChannel.getChannel(), new QuantityType<>(val.getAsBigDecimal(), SIUnits.CELSIUS));
596 case "electriccurrent":
597 updateState(basicChannel.getChannel(),
598 new QuantityType<>(val.getAsBigDecimal(), SmartHomeUnits.AMPERE));
601 updateState(basicChannel.getChannel(), new QuantityType<>(val.getAsBigDecimal(), SmartHomeUnits.WATT));
604 updateState(basicChannel.getChannel(), new QuantityType<>(val.getAsBigDecimal(), SmartHomeUnits.HOUR));
607 updateState(basicChannel.getChannel(), new DecimalType(val.getAsBigDecimal()));
612 public void onMessageReceived(MiIoSendCommand response) {
613 super.onMessageReceived(response);
614 if (response.isError()) {
618 switch (response.getCommand()) {
624 if (response.getResult().isJsonArray()) {
625 updatePropsFromJsonArray(response);
626 } else if (response.getResult().isJsonObject()) {
627 updatePropsFromJsonObject(response);
631 if (refreshListCustomCommands.containsKey(response.getMethod())) {
632 logger.debug("Processing custom refresh command response for !{}", response.getMethod());
633 MiIoBasicChannel ch = refreshListCustomCommands.get(response.getMethod());
634 if (response.getResult().isJsonArray()) {
635 JsonArray cmdResponse = response.getResult().getAsJsonArray();
636 final String transformation = ch.getTransfortmation();
637 if (transformation == null || transformation.isBlank()) {
638 updateChannel(ch, ch.getChannel(),
639 cmdResponse.get(0).isJsonPrimitive() ? cmdResponse.get(0)
640 : new JsonPrimitive(cmdResponse.get(0).toString()));
642 updateChannel(ch, ch.getChannel(), cmdResponse);
645 updateChannel(ch, ch.getChannel(), new JsonPrimitive(response.getResult().toString()));
650 } catch (Exception e) {
651 logger.debug("Error while handing message {}", response.getResponse(), e);