2 * Copyright (c) 2010-2021 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.io.IOException;
18 import java.math.BigDecimal;
19 import java.time.Instant;
20 import java.time.LocalDateTime;
21 import java.util.HashMap;
23 import java.util.Map.Entry;
24 import java.util.concurrent.ConcurrentHashMap;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.ScheduledThreadPoolExecutor;
28 import java.util.concurrent.TimeUnit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.miio.internal.Message;
33 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
34 import org.openhab.binding.miio.internal.MiIoCommand;
35 import org.openhab.binding.miio.internal.MiIoCrypto;
36 import org.openhab.binding.miio.internal.MiIoCryptoException;
37 import org.openhab.binding.miio.internal.MiIoDevices;
38 import org.openhab.binding.miio.internal.MiIoInfoApDTO;
39 import org.openhab.binding.miio.internal.MiIoInfoDTO;
40 import org.openhab.binding.miio.internal.MiIoMessageListener;
41 import org.openhab.binding.miio.internal.MiIoSendCommand;
42 import org.openhab.binding.miio.internal.Utils;
43 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
44 import org.openhab.binding.miio.internal.cloud.CloudConnector;
45 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
46 import org.openhab.core.cache.ExpiringCache;
47 import org.openhab.core.common.NamedThreadFactory;
48 import org.openhab.core.config.core.Configuration;
49 import org.openhab.core.library.types.DecimalType;
50 import org.openhab.core.library.types.StringType;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.ThingTypeUID;
56 import org.openhab.core.thing.binding.BaseThingHandler;
57 import org.openhab.core.thing.binding.builder.ThingBuilder;
58 import org.openhab.core.types.Command;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import com.google.gson.Gson;
63 import com.google.gson.GsonBuilder;
64 import com.google.gson.JsonObject;
65 import com.google.gson.JsonPrimitive;
66 import com.google.gson.JsonSyntaxException;
69 * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Marcel Verpaalen - Initial contribution
75 public abstract class MiIoAbstractHandler extends BaseThingHandler implements MiIoMessageListener {
76 protected static final int MAX_QUEUE = 5;
77 protected static final Gson GSON = new GsonBuilder().create();
78 protected static final String TIMESTAMP = "timestamp";
80 protected ScheduledExecutorService miIoScheduler = new ScheduledThreadPoolExecutor(3,
81 new NamedThreadFactory("binding-" + getThing().getUID().getAsString(), true));
83 protected @Nullable ScheduledFuture<?> pollingJob;
84 protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
85 protected boolean isIdentified;
87 protected byte[] token = new byte[0];
89 protected @Nullable MiIoBindingConfiguration configuration;
90 protected @Nullable MiIoAsyncCommunication miioCom;
91 protected CloudConnector cloudConnector;
92 protected String cloudServer = "";
95 protected Map<Integer, String> cmds = new ConcurrentHashMap<>();
96 protected Map<String, Object> deviceVariables = new HashMap<>();
97 protected final ExpiringCache<String> network = new ExpiringCache<>(CACHE_EXPIRY_NETWORK, () -> {
98 int ret = sendCommand(MiIoCommand.MIIO_INFO);
104 protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
105 protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
107 private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
108 protected MiIoDatabaseWatchService miIoDatabaseWatchService;
110 public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
111 CloudConnector cloudConnector) {
113 this.miIoDatabaseWatchService = miIoDatabaseWatchService;
114 this.cloudConnector = cloudConnector;
118 public abstract void handleCommand(ChannelUID channelUID, Command command);
120 protected boolean handleCommandsChannels(ChannelUID channelUID, Command command) {
121 String cmd = processSubstitutions(command.toString(), deviceVariables);
122 if (channelUID.getId().equals(CHANNEL_COMMAND)) {
123 cmds.put(sendCommand(cmd), channelUID.getId());
126 if (channelUID.getId().equals(CHANNEL_RPC)) {
127 cmds.put(sendCommand(cmd, cloudServer), channelUID.getId());
134 public void initialize() {
135 logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
136 getThing().getThingTypeUID());
138 ScheduledThreadPoolExecutor miIoScheduler = new ScheduledThreadPoolExecutor(3,
139 new NamedThreadFactory("binding-" + getThing().getUID().getAsString(), true));
140 miIoScheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
141 miIoScheduler.setRemoveOnCancelPolicy(true);
142 this.miIoScheduler = miIoScheduler;
144 final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
145 this.configuration = configuration;
146 if (configuration.host.isEmpty()) {
147 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
148 "IP address required. Configure IP address");
151 if (!tokenCheckPass(configuration.token)) {
152 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token");
155 this.cloudServer = configuration.cloudServer;
156 isIdentified = false;
157 deviceVariables.put(TIMESTAMP, Instant.now().getEpochSecond());
158 deviceVariables.put(PROPERTY_DID, configuration.deviceId);
159 miIoScheduler.schedule(this::initializeData, 1, TimeUnit.SECONDS);
160 int pollingPeriod = configuration.refreshInterval;
161 if (pollingPeriod > 0) {
162 pollingJob = miIoScheduler.scheduleWithFixedDelay(() -> {
165 } catch (Exception e) {
166 logger.debug("Unexpected error during refresh.", e);
168 }, 10, pollingPeriod, TimeUnit.SECONDS);
169 logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
171 logger.debug("Polling job disabled. for '{}'", getThing().getUID());
172 miIoScheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
174 updateStatus(ThingStatus.OFFLINE);
177 private boolean tokenCheckPass(@Nullable String tokenSting) {
178 if (tokenSting == null) {
181 switch (tokenSting.length()) {
183 token = tokenSting.getBytes();
186 if (!IGNORED_TOKENS.contains(tokenSting)) {
187 token = Utils.hexStringToByteArray(tokenSting);
193 token = Utils.hexStringToByteArray(MiIoCrypto.decryptToken(Utils.hexStringToByteArray(tokenSting)));
194 logger.debug("IOS token decrypted to {}", Utils.getHex(token));
195 } catch (MiIoCryptoException e) {
196 logger.warn("Could not decrypt token {}{}", tokenSting, e.getMessage());
206 public void dispose() {
207 logger.debug("Disposing Xiaomi Mi IO handler '{}'", getThing().getUID());
208 miIoScheduler.shutdown();
209 final ScheduledFuture<?> pollingJob = this.pollingJob;
210 if (pollingJob != null) {
211 pollingJob.cancel(true);
212 this.pollingJob = null;
214 final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
215 if (miioCom != null) {
216 lastId = miioCom.getId();
217 miioCom.unregisterListener(this);
221 miIoScheduler.shutdownNow();
224 protected int sendCommand(MiIoCommand command) {
225 return sendCommand(command, "[]");
228 protected int sendCommand(MiIoCommand command, String params) {
229 return sendCommand(command.getCommand(), processSubstitutions(params, deviceVariables), getCloudServer(), "");
232 protected int sendCommand(String commandString) {
233 return sendCommand(commandString, getCloudServer());
237 * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
239 * [] brackets. This to allow for unimplemented commands to be executed (e.g. get detailed historical cleaning
242 * @param commandString command to be executed
243 * @param cloud server to be used or empty string for direct sending to the device
244 * @return vacuum response
246 protected int sendCommand(String commandString, String cloudServer) {
247 String command = commandString.trim();
248 command = processSubstitutions(commandString.trim(), deviceVariables);
250 int sb = command.indexOf("[");
251 int cb = command.indexOf("{");
252 if (Math.max(sb, cb) > 0) {
253 int loc = (Math.min(sb, cb) > 0 ? Math.min(sb, cb) : Math.max(sb, cb));
254 param = command.substring(loc).trim();
255 command = command.substring(0, loc).trim();
257 return sendCommand(command, param, cloudServer, "");
260 protected int sendCommand(String command, String params, String cloudServer) {
261 return sendCommand(command, processSubstitutions(params, deviceVariables), cloudServer, "");
265 * Sends commands to the {@link MiIoAsyncCommunication} for transmission to the Mi devices or cloud
267 * @param command (method) to be queued for execution
268 * @param parameters to be send with the command
269 * @param cloud server to be used or empty string for direct sending to the device
270 * @param sending subdevice or empty string for regular device
273 protected int sendCommand(String command, String params, String cloudServer, String sender) {
275 final MiIoAsyncCommunication connection = getConnection();
276 return (connection != null) ? connection.queueCommand(command, params, cloudServer, sender) : 0;
277 } catch (MiIoCryptoException | IOException e) {
278 logger.debug("Command {} for {} failed (type: {}): {}", command.toString(), getThing().getUID(),
279 getThing().getThingTypeUID(), e.getLocalizedMessage());
280 disconnected(e.getMessage());
285 String getCloudServer() {
286 // This can be improved in the future with additional / more advanced options like e.g. directFirst which would
287 // use direct communications and in case of failures fall back to cloud communication. For now we keep it
288 // simple and only have the option for cloud or direct.
289 final MiIoBindingConfiguration configuration = this.configuration;
290 if (configuration != null) {
291 return configuration.communication.equals("cloud") ? cloudServer : "";
296 protected boolean skipUpdate() {
297 final MiIoAsyncCommunication miioCom = this.miioCom;
298 if (!hasConnection() || miioCom == null) {
299 logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
302 if (getThing().getStatusInfo().getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)) {
303 logger.debug("Skipping periodic update for '{}'. Thing Status {}", getThing().getUID().toString(),
304 getThing().getStatusInfo().getStatusDetail());
305 sendCommand(MiIoCommand.MIIO_INFO);
308 if (miioCom.getQueueLength() > MAX_QUEUE) {
309 logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
310 miioCom.getQueueLength());
316 protected abstract void updateData();
318 protected boolean updateNetwork(JsonObject networkData) {
320 final MiIoInfoDTO miioInfo = GSON.fromJson(networkData, MiIoInfoDTO.class);
321 final MiIoInfoApDTO ap = miioInfo != null ? miioInfo.ap : null;
322 if (miioInfo != null && ap != null) {
323 if (ap.getSsid() != null) {
324 updateState(CHANNEL_SSID, new StringType(ap.getSsid()));
326 if (ap.getBssid() != null) {
327 updateState(CHANNEL_BSSID, new StringType(ap.getBssid()));
329 if (ap.getRssi() != null) {
330 updateState(CHANNEL_RSSI, new DecimalType(ap.getRssi()));
331 } else if (ap.getWifiRssi() != null) {
332 updateState(CHANNEL_RSSI, new DecimalType(ap.getWifiRssi()));
334 logger.debug("No RSSI info in response");
336 if (miioInfo.life != null) {
337 updateState(CHANNEL_LIFE, new DecimalType(miioInfo.life));
341 } catch (NumberFormatException e) {
342 logger.debug("Could not parse number in network response: {}", networkData);
343 } catch (JsonSyntaxException e) {
344 logger.debug("Could not parse network response: {}", networkData, e);
349 protected boolean hasConnection() {
350 return getConnection() != null;
353 protected void disconnectedNoResponse() {
354 disconnected("No Response from device");
357 protected void disconnected(@Nullable String message) {
358 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
359 message != null ? message : "");
360 final MiIoAsyncCommunication miioCom = this.miioCom;
361 if (miioCom != null) {
362 lastId = miioCom.getId();
367 protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
368 if (miioCom != null) {
371 final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
372 if (configuration.host.isBlank()) {
376 String deviceId = configuration.deviceId;
377 if (deviceId.length() == 8 && deviceId.matches("^.*[a-zA-Z]+.*$")) {
379 "As per openHAB version 3.2 the deviceId is no longer a string with hexadecimals, instead it is a string with the numeric respresentation of the deviceId. If you continue seeing this message, update deviceId in your thing configuration. Expected change for thing '{}': Update current deviceId: '{}' to '{}'",
380 getThing().getUID(), deviceId, Utils.fromHEX(deviceId));
384 if (!deviceId.isBlank() && tokenCheckPass(configuration.token)) {
385 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token, deviceId,
386 lastId, configuration.timeout, cloudConnector);
387 if (getCloudServer().isBlank()) {
388 logger.debug("Ping Mi deviceId '{}' at {}", deviceId, configuration.host);
389 Message miIoResponse = miioCom.sendPing(configuration.host);
390 if (miIoResponse != null) {
391 logger.debug("Ping response from deviceId '{}' at {}. Time stamp: {}, OH time {}, delta {}",
392 Utils.fromHEX(Utils.getHex(miIoResponse.getDeviceId())), configuration.host,
393 miIoResponse.getTimestamp(), LocalDateTime.now(), miioCom.getTimeDelta());
394 miioCom.registerListener(this);
395 this.miioCom = miioCom;
401 miioCom.registerListener(this);
402 this.miioCom = miioCom;
406 logger.debug("No deviceId defined. Retrieving Mi deviceId");
407 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token, "", lastId,
408 configuration.timeout, cloudConnector);
409 Message miIoResponse = miioCom.sendPing(configuration.host);
410 if (miIoResponse != null) {
411 deviceId = Utils.fromHEX(Utils.getHex(miIoResponse.getDeviceId()));
412 logger.debug("Ping response from deviceId '{}' at {}. Time stamp: {}, OH time {}, delta {}",
413 deviceId, configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
414 miioCom.getTimeDelta());
415 miioCom.setDeviceId(deviceId);
416 logger.debug("Using retrieved Mi deviceId: {}", deviceId);
417 updateDeviceIdConfig(deviceId);
418 miioCom.registerListener(this);
419 this.miioCom = miioCom;
425 logger.debug("Ping response from deviceId '{}' at {} FAILED", configuration.deviceId, configuration.host);
426 disconnectedNoResponse();
428 } catch (IOException e) {
429 logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
430 disconnected(e.getMessage());
435 private void updateDeviceIdConfig(String deviceId) {
436 if (!deviceId.isEmpty()) {
437 updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
438 Configuration config = editConfiguration();
439 config.put(PROPERTY_DID, deviceId);
440 updateConfiguration(config);
441 deviceVariables.put(PROPERTY_DID, deviceId);
443 logger.debug("Could not update config with deviceId: {}", deviceId);
447 protected boolean initializeData() {
448 this.miioCom = getConnection();
452 protected void refreshNetwork() {
456 protected void defineDeviceType(JsonObject miioInfo) {
457 updateProperties(miioInfo);
458 isIdentified = updateThingType(miioInfo);
461 private void updateProperties(JsonObject miioInfo) {
462 final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
466 Map<String, String> properties = editProperties();
467 if (info.model != null) {
468 properties.put(Thing.PROPERTY_MODEL_ID, info.model);
470 if (info.fwVer != null) {
471 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
473 if (info.hwVer != null) {
474 properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
476 if (info.wifiFwVer != null) {
477 properties.put("wifiFirmware", info.wifiFwVer);
479 if (info.mcuFwVer != null) {
480 properties.put("mcuFirmware", info.mcuFwVer);
482 deviceVariables.putAll(properties);
483 updateProperties(properties);
486 protected String processSubstitutions(String cmd, Map<String, Object> deviceVariables) {
487 if (!cmd.contains("$")) {
490 String returnCmd = cmd.replace("\"$", "$").replace("$\"", "$");
491 String cmdParts[] = cmd.split("\\$");
492 if (logger.isTraceEnabled()) {
493 logger.debug("processSubstitutions {} ", cmd);
494 for (Entry<String, Object> e : deviceVariables.entrySet()) {
495 logger.debug("key, value: {} -> {}", e.getKey(), e.getValue());
498 for (String substitute : cmdParts) {
499 if (deviceVariables.containsKey(substitute)) {
500 String replacementString = "";
501 Object replacement = deviceVariables.get(substitute);
502 if (replacement == null) {
503 logger.debug("Replacement for '{}' is null. skipping replacement", substitute);
506 if (replacement instanceof Integer || replacement instanceof Long || replacement instanceof Double
507 || replacement instanceof BigDecimal || replacement instanceof Boolean) {
508 replacementString = replacement.toString();
509 } else if (replacement instanceof JsonPrimitive) {
510 replacementString = ((JsonPrimitive) replacement).getAsString();
511 } else if (replacement instanceof String) {
512 replacementString = "\"" + (String) replacement + "\"";
514 replacementString = String.valueOf(replacement);
516 returnCmd = returnCmd.replace("$" + substitute + "$", replacementString);
522 protected boolean updateThingType(JsonObject miioInfo) {
523 MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
524 String model = miioInfo.get("model").getAsString();
525 miDevice = MiIoDevices.getType(model);
526 if (configuration.model.isEmpty()) {
527 Configuration config = editConfiguration();
528 config.put(PROPERTY_MODEL, model);
529 updateConfiguration(config);
530 configuration = getConfigAs(MiIoBindingConfiguration.class);
532 if (!configuration.model.equals(model)) {
533 logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
534 configuration.model);
536 if (miDevice.getThingType().equals(getThing().getThingTypeUID())
537 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
538 && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
539 logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
540 miDevice.getThingType().toString());
543 if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
544 || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
548 "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
549 miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
550 miDevice.getThingType().toString());
558 * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
561 * @param modelId String with the model id
563 private void changeType(final String modelId) {
564 final ScheduledFuture<?> pollingJob = this.pollingJob;
565 if (pollingJob != null) {
566 pollingJob.cancel(true);
567 this.pollingJob = null;
569 miIoScheduler.schedule(() -> {
570 String label = getThing().getLabel();
571 if (label == null || label.startsWith("Xiaomi Mi Device")) {
572 ThingBuilder thingBuilder = editThing();
573 thingBuilder.withLabel(miDevice.getDescription());
574 updateThing(thingBuilder.build());
576 logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
577 modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
578 miDevice.getThingType().toString());
579 ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
580 if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
581 && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
582 thingTypeUID = THING_TYPE_BASIC;
584 changeThingType(thingTypeUID, getConfig());
585 }, 10, TimeUnit.SECONDS);
589 public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
590 updateStatus(status, statusDetail);
594 public void onMessageReceived(MiIoSendCommand response) {
595 logger.debug("Received response for device {} type: {}, result: {}, fullresponse: {}",
596 getThing().getUID().getId(), response.getCommand(), response.getResult(), response.getResponse());
597 if (response.isError()) {
598 logger.debug("Error received for command '{}': {}.", response.getCommandString(),
599 response.getResponse().get("error"));
600 if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
601 network.invalidateValue();
606 switch (response.getCommand()) {
609 defineDeviceType(response.getResult().getAsJsonObject());
611 updateNetwork(response.getResult().getAsJsonObject());
616 if (cmds.containsKey(response.getId())) {
617 String channel = cmds.get(response.getId());
618 if (channel != null && (CHANNEL_COMMAND.contentEquals(channel) || CHANNEL_RPC.contentEquals(channel))) {
619 updateState(channel, new StringType(response.getResponse().toString()));
620 cmds.remove(response.getId());
623 } catch (Exception e) {
624 logger.debug("Error while handing message {}", response.getResponse(), e);