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.CHANNEL_COMMAND;
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 org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
29 import org.openhab.binding.miio.internal.MiIoCommand;
30 import org.openhab.binding.miio.internal.MiIoCryptoException;
31 import org.openhab.binding.miio.internal.MiIoSendCommand;
32 import org.openhab.binding.miio.internal.Utils;
33 import org.openhab.binding.miio.internal.basic.ActionConditions;
34 import org.openhab.binding.miio.internal.basic.CommandParameterType;
35 import org.openhab.binding.miio.internal.basic.Conversions;
36 import org.openhab.binding.miio.internal.basic.MiIoBasicChannel;
37 import org.openhab.binding.miio.internal.basic.MiIoBasicDevice;
38 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
39 import org.openhab.binding.miio.internal.basic.MiIoDeviceAction;
40 import org.openhab.binding.miio.internal.basic.MiIoDeviceActionCondition;
41 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
42 import org.openhab.core.cache.ExpiringCache;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.HSBType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PercentType;
47 import org.openhab.core.library.types.StringType;
48 import org.openhab.core.thing.Channel;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.binding.builder.ChannelBuilder;
52 import org.openhab.core.thing.binding.builder.ThingBuilder;
53 import org.openhab.core.thing.type.ChannelTypeUID;
54 import org.openhab.core.types.Command;
55 import org.openhab.core.types.RefreshType;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
59 import com.google.gson.Gson;
60 import com.google.gson.GsonBuilder;
61 import com.google.gson.JsonArray;
62 import com.google.gson.JsonElement;
63 import com.google.gson.JsonIOException;
64 import com.google.gson.JsonObject;
65 import com.google.gson.JsonPrimitive;
66 import com.google.gson.JsonSyntaxException;
69 * The {@link MiIoBasicHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Marcel Verpaalen - Initial contribution
75 public class MiIoBasicHandler extends MiIoAbstractHandler {
77 private final Logger logger = LoggerFactory.getLogger(MiIoBasicHandler.class);
78 private boolean hasChannelStructure;
80 private final ExpiringCache<Boolean> updateDataCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
81 scheduler.schedule(this::updateData, 0, TimeUnit.SECONDS);
85 List<MiIoBasicChannel> refreshList = new ArrayList<>();
87 private @Nullable MiIoBasicDevice miioDevice;
88 private Map<ChannelUID, MiIoBasicChannel> actions = new HashMap<>();
90 public MiIoBasicHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService) {
91 super(thing, miIoDatabaseWatchService);
95 public void initialize() {
97 hasChannelStructure = false;
99 refreshList = new ArrayList<>();
103 public void handleCommand(ChannelUID channelUID, Command command) {
104 if (command == RefreshType.REFRESH) {
105 if (updateDataCache.isExpired()) {
106 logger.debug("Refreshing {}", channelUID);
107 updateDataCache.getValue();
109 logger.debug("Refresh {} skipped. Already refreshing", channelUID);
113 if (channelUID.getId().equals(CHANNEL_COMMAND)) {
114 cmds.put(sendCommand(command.toString()), command.toString());
117 logger.debug("Locating action for channel '{}': '{}'", channelUID.getId(), command);
118 if (!actions.isEmpty()) {
119 if (actions.containsKey(channelUID)) {
121 MiIoBasicChannel miIoBasicChannel = actions.get(channelUID);
122 for (MiIoDeviceAction action : miIoBasicChannel.getActions()) {
124 JsonElement value = null;
125 JsonArray parameters = action.getParameters().deepCopy();
126 for (int i = 0; i < action.getParameters().size(); i++) {
127 JsonElement p = action.getParameters().get(i);
128 if (p.isJsonPrimitive() && p.getAsString().toLowerCase().contains("$value$")) {
133 String cmd = action.getCommand();
134 CommandParameterType paramType = action.getparameterType();
135 if (paramType == CommandParameterType.COLOR) {
136 if (command instanceof HSBType) {
137 HSBType hsb = (HSBType) command;
138 Color color = Color.getHSBColor(hsb.getHue().floatValue() / 360,
139 hsb.getSaturation().floatValue() / 100, hsb.getBrightness().floatValue() / 100);
140 value = new JsonPrimitive(
141 (color.getRed() << 16) + (color.getGreen() << 8) + color.getBlue());
142 } else if (command instanceof DecimalType) {
143 // actually brightness is being set instead of a color
144 value = new JsonPrimitive(((DecimalType) command).toBigDecimal());
145 } else if (command instanceof OnOffType) {
146 value = new JsonPrimitive(command == OnOffType.ON ? 100 : 0);
148 logger.debug("Unsupported command for COLOR: {}", command);
150 } else if (command instanceof OnOffType) {
151 if (paramType == CommandParameterType.ONOFF) {
152 value = new JsonPrimitive(command == OnOffType.ON ? "on" : "off");
153 } else if (paramType == CommandParameterType.ONOFFPARA) {
154 cmd = cmd.replace("*", command == OnOffType.ON ? "on" : "off");
155 value = new JsonArray();
156 } else if (paramType == CommandParameterType.ONOFFBOOL) {
157 boolean boolCommand = command == OnOffType.ON;
158 value = new JsonPrimitive(boolCommand);
159 } else if (paramType == CommandParameterType.ONOFFBOOLSTRING) {
160 value = new JsonPrimitive(command == OnOffType.ON ? "true" : "false");
162 } else if (command instanceof DecimalType) {
163 value = new JsonPrimitive(((DecimalType) command).toBigDecimal());
164 } else if (command instanceof StringType) {
165 if (paramType == CommandParameterType.STRING) {
166 value = new JsonPrimitive(command.toString().toLowerCase());
167 } else if (paramType == CommandParameterType.CUSTOMSTRING) {
168 value = new JsonPrimitive(parameters.get(valuePos).getAsString().replace("$value",
169 command.toString().toLowerCase()));
172 value = new JsonPrimitive(command.toString().toLowerCase());
174 if (paramType == CommandParameterType.EMPTY) {
175 value = new JsonArray();
177 final MiIoDeviceActionCondition miIoDeviceActionCondition = action.getCondition();
178 if (miIoDeviceActionCondition != null) {
179 value = ActionConditions.executeAction(miIoDeviceActionCondition, deviceVariables, value,
182 // Check for miot channel
184 if (action.isMiOtAction()) {
185 value = miotActionTransform(action, miIoBasicChannel, value);
186 } else if (miIoBasicChannel.isMiOt()) {
187 value = miotTransform(miIoBasicChannel, value);
190 if (paramType != CommandParameterType.NONE && paramType != CommandParameterType.ONOFFPARA
192 if (parameters.size() > 0) {
193 parameters.set(valuePos, value);
195 parameters.add(value);
198 cmd = cmd + parameters.toString();
200 logger.debug("Sending command {}", cmd);
203 if (miIoDeviceActionCondition != null) {
204 logger.debug("Conditional command {} not send, condition '{}' not met", cmd,
205 miIoDeviceActionCondition.getName());
207 logger.debug("Command not send. Value null");
212 logger.debug("Channel Id {} not in mapping.", channelUID.getId());
213 if (logger.isTraceEnabled()) {
214 for (ChannelUID a : actions.keySet()) {
215 logger.trace("Available entries: {} : {}", a, actions.get(a).getFriendlyName());
219 updateDataCache.invalidateValue();
222 logger.debug("Actions not loaded yet");
226 private @Nullable JsonElement miotTransform(MiIoBasicChannel miIoBasicChannel, @Nullable JsonElement value) {
227 JsonObject json = new JsonObject();
228 json.addProperty("did", miIoBasicChannel.getChannel());
229 json.addProperty("siid", miIoBasicChannel.getSiid());
230 json.addProperty("piid", miIoBasicChannel.getPiid());
231 json.add("value", value);
235 private @Nullable JsonElement miotActionTransform(MiIoDeviceAction action, MiIoBasicChannel miIoBasicChannel,
236 @Nullable JsonElement value) {
237 JsonObject json = new JsonObject();
238 json.addProperty("did", miIoBasicChannel.getChannel());
239 json.addProperty("siid", action.getSiid());
240 json.addProperty("aiid", action.getAiid());
242 json.add("in", value);
248 protected synchronized void updateData() {
249 logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
250 final MiIoAsyncCommunication miioCom = getConnection();
252 if (!hasConnection() || skipUpdate() || miioCom == null) {
255 checkChannelStructure();
257 miioCom.queueCommand(MiIoCommand.MIIO_INFO);
259 final MiIoBasicDevice midevice = miioDevice;
260 if (midevice != null) {
261 refreshProperties(midevice);
264 } catch (Exception e) {
265 logger.debug("Error while updating '{}': ", getThing().getUID().toString(), e);
269 private boolean refreshProperties(MiIoBasicDevice device) {
270 MiIoCommand command = MiIoCommand.getCommand(device.getDevice().getPropertyMethod());
271 int maxProperties = device.getDevice().getMaxProperties();
272 JsonArray getPropString = new JsonArray();
273 for (MiIoBasicChannel miChannel : refreshList) {
274 JsonElement property;
275 if (miChannel.isMiOt()) {
276 JsonObject json = new JsonObject();
277 json.addProperty("did", miChannel.getProperty());
278 json.addProperty("siid", miChannel.getSiid());
279 json.addProperty("piid", miChannel.getPiid());
282 property = new JsonPrimitive(miChannel.getProperty());
284 getPropString.add(property);
285 if (getPropString.size() >= maxProperties) {
286 sendRefreshProperties(command, getPropString);
287 getPropString = new JsonArray();
290 if (getPropString.size() > 0) {
291 sendRefreshProperties(command, getPropString);
296 private void sendRefreshProperties(MiIoCommand command, JsonArray getPropString) {
298 final MiIoAsyncCommunication miioCom = this.miioCom;
299 if (miioCom != null) {
300 miioCom.queueCommand(command, getPropString.toString());
302 } catch (MiIoCryptoException | IOException e) {
303 logger.debug("Send refresh failed {}", e.getMessage(), e);
308 * Checks if the channel structure has been build already based on the model data. If not build it.
310 private void checkChannelStructure() {
311 final MiIoBindingConfiguration configuration = this.configuration;
312 if (configuration == null) {
315 if (!hasChannelStructure) {
316 if (configuration.model == null || configuration.model.isEmpty()) {
317 logger.debug("Model needs to be determined");
318 isIdentified = false;
320 hasChannelStructure = buildChannelStructure(configuration.model);
323 if (hasChannelStructure) {
324 refreshList = new ArrayList<>();
325 final MiIoBasicDevice miioDevice = this.miioDevice;
326 if (miioDevice != null) {
327 for (MiIoBasicChannel miChannel : miioDevice.getDevice().getChannels()) {
328 if (miChannel.getRefresh()) {
329 refreshList.add(miChannel);
337 private boolean buildChannelStructure(String deviceName) {
338 logger.debug("Building Channel Structure for {} - Model: {}", getThing().getUID().toString(), deviceName);
339 URL fn = miIoDatabaseWatchService.getDatabaseUrl(deviceName);
341 logger.warn("Database entry for model '{}' cannot be found.", deviceName);
345 JsonObject deviceMapping = Utils.convertFileToJSON(fn);
346 logger.debug("Using device database: {} for device {}", fn.getFile(), deviceName);
347 Gson gson = new GsonBuilder().serializeNulls().create();
348 miioDevice = gson.fromJson(deviceMapping, MiIoBasicDevice.class);
349 for (Channel ch : getThing().getChannels()) {
350 logger.debug("Current thing channels {}, type: {}", ch.getUID(), ch.getChannelTypeUID());
352 ThingBuilder thingBuilder = editThing();
353 int channelsAdded = 0;
355 // make a map of the actions
356 actions = new HashMap<>();
357 final MiIoBasicDevice device = this.miioDevice;
358 if (device != null) {
359 for (MiIoBasicChannel miChannel : device.getDevice().getChannels()) {
360 logger.debug("properties {}", miChannel);
361 if (!miChannel.getType().isEmpty()) {
362 ChannelUID channelUID = addChannel(thingBuilder, miChannel.getChannel(),
363 miChannel.getChannelType(), miChannel.getType(), miChannel.getFriendlyName());
364 if (channelUID != null) {
365 actions.put(channelUID, miChannel);
368 logger.debug("Channel for {} ({}) not loaded", miChannel.getChannel(),
369 miChannel.getFriendlyName());
372 logger.debug("Channel {} ({}), not loaded, missing type", miChannel.getChannel(),
373 miChannel.getFriendlyName());
377 // only update if channels were added/removed
378 if (channelsAdded > 0) {
379 logger.debug("Current thing channels added: {}", channelsAdded);
380 updateThing(thingBuilder.build());
383 } catch (JsonIOException | JsonSyntaxException e) {
384 logger.warn("Error parsing database Json", e);
385 } catch (IOException e) {
386 logger.warn("Error reading database file", e);
387 } catch (Exception e) {
388 logger.warn("Error creating channel structure", e);
393 private @Nullable ChannelUID addChannel(ThingBuilder thingBuilder, @Nullable String channel, String channelType,
394 @Nullable String datatype, String friendlyName) {
395 if (channel == null || channel.isEmpty() || datatype == null || datatype.isEmpty()) {
396 logger.info("Channel '{}', UID '{}' cannot be added incorrectly configured database. ", channel,
397 getThing().getUID());
400 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channel);
401 ChannelTypeUID channelTypeUID = new ChannelTypeUID(channelType);
403 // TODO: Need to understand if this harms anything. If yes, channel only to be added when not there already.
404 // current way allows to have no issues when channels are changing.
405 if (getThing().getChannel(channel) != null) {
406 logger.info("Channel '{}' for thing {} already exist... removing", channel, getThing().getUID());
407 thingBuilder.withoutChannel(new ChannelUID(getThing().getUID(), channel));
409 Channel newChannel = ChannelBuilder.create(channelUID, datatype).withType(channelTypeUID)
410 .withLabel(friendlyName).build();
411 thingBuilder.withChannel(newChannel);
415 private @Nullable MiIoBasicChannel getChannel(String parameter) {
416 for (MiIoBasicChannel refreshEntry : refreshList) {
417 if (refreshEntry.getProperty().equals(parameter)) {
421 logger.trace("Did not find channel for {} in {}", parameter, refreshList);
425 private void updatePropsFromJsonArray(MiIoSendCommand response) {
426 JsonArray res = response.getResult().getAsJsonArray();
427 JsonArray para = parser.parse(response.getCommandString()).getAsJsonObject().get("params").getAsJsonArray();
428 if (res.size() != para.size()) {
429 logger.debug("Unexpected size different. Request size {}, response size {}. (Req: {}, Resp:{})",
430 para.size(), res.size(), para, res);
433 for (int i = 0; i < para.size(); i++) {
434 // This is a miot parameter
436 final JsonElement paraElement = para.get(i);
437 if (paraElement.isJsonObject()) { // miot channel
438 param = paraElement.getAsJsonObject().get("did").getAsString();
440 param = paraElement.getAsString();
442 JsonElement val = res.get(i);
443 if (val.isJsonNull()) {
444 logger.debug("Property '{}' returned null (is it supported?).", param);
446 } else if (val.isJsonObject()) { // miot channel
447 val = val.getAsJsonObject().get("value");
449 MiIoBasicChannel basicChannel = getChannel(param);
450 updateChannel(basicChannel, param, val);
454 private void updatePropsFromJsonObject(MiIoSendCommand response) {
455 JsonObject res = response.getResult().getAsJsonObject();
456 for (Object k : res.keySet()) {
457 String param = (String) k;
458 JsonElement val = res.get(param);
459 if (val.isJsonNull()) {
460 logger.debug("Property '{}' returned null (is it supported?).", param);
463 MiIoBasicChannel basicChannel = getChannel(param);
464 updateChannel(basicChannel, param, val);
468 private void updateChannel(@Nullable MiIoBasicChannel basicChannel, String param, JsonElement value) {
469 JsonElement val = value;
470 if (basicChannel == null) {
471 logger.debug("Channel not found for {}", param);
474 final String transformation = basicChannel.getTransfortmation();
475 if (transformation != null) {
476 JsonElement transformed = Conversions.execute(transformation, val);
477 logger.debug("Transformed with '{}': {} {} -> {} ", transformation, basicChannel.getFriendlyName(), val,
482 switch (basicChannel.getType().toLowerCase()) {
484 updateState(basicChannel.getChannel(), new DecimalType(val.getAsBigDecimal()));
487 updateState(basicChannel.getChannel(), new PercentType(val.getAsBigDecimal()));
490 updateState(basicChannel.getChannel(), new StringType(val.getAsString()));
493 updateState(basicChannel.getChannel(), val.getAsString().toLowerCase().equals("on")
494 || val.getAsString().toLowerCase().equals("true") ? OnOffType.ON : OnOffType.OFF);
497 Color rgb = new Color(val.getAsInt());
498 HSBType hsb = HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue());
499 updateState(basicChannel.getChannel(), hsb);
502 logger.debug("No update logic for channeltype '{}' ", basicChannel.getType());
504 } catch (Exception e) {
505 logger.debug("Error updating {} property {} with '{}' : {}: {}", getThing().getUID(),
506 basicChannel.getChannel(), val, e.getClass().getCanonicalName(), e.getMessage());
507 logger.trace("Property update error detail:", e);
512 public void onMessageReceived(MiIoSendCommand response) {
513 super.onMessageReceived(response);
514 if (response.isError()) {
518 switch (response.getCommand()) {
524 if (response.getResult().isJsonArray()) {
525 updatePropsFromJsonArray(response);
526 } else if (response.getResult().isJsonObject()) {
527 updatePropsFromJsonObject(response);
533 } catch (Exception e) {
534 logger.debug("Error while handing message {}", response.getResponse(), e);