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.time.LocalDateTime;
19 import java.util.HashMap;
21 import java.util.concurrent.ConcurrentHashMap;
22 import java.util.concurrent.ScheduledExecutorService;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.ScheduledThreadPoolExecutor;
25 import java.util.concurrent.TimeUnit;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.binding.miio.internal.Message;
30 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
31 import org.openhab.binding.miio.internal.MiIoCommand;
32 import org.openhab.binding.miio.internal.MiIoCrypto;
33 import org.openhab.binding.miio.internal.MiIoCryptoException;
34 import org.openhab.binding.miio.internal.MiIoDevices;
35 import org.openhab.binding.miio.internal.MiIoInfoDTO;
36 import org.openhab.binding.miio.internal.MiIoMessageListener;
37 import org.openhab.binding.miio.internal.MiIoSendCommand;
38 import org.openhab.binding.miio.internal.Utils;
39 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
40 import org.openhab.binding.miio.internal.cloud.CloudConnector;
41 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
42 import org.openhab.core.cache.ExpiringCache;
43 import org.openhab.core.common.NamedThreadFactory;
44 import org.openhab.core.config.core.Configuration;
45 import org.openhab.core.library.types.DecimalType;
46 import org.openhab.core.library.types.StringType;
47 import org.openhab.core.thing.ChannelUID;
48 import org.openhab.core.thing.Thing;
49 import org.openhab.core.thing.ThingStatus;
50 import org.openhab.core.thing.ThingStatusDetail;
51 import org.openhab.core.thing.ThingTypeUID;
52 import org.openhab.core.thing.binding.BaseThingHandler;
53 import org.openhab.core.thing.binding.builder.ThingBuilder;
54 import org.openhab.core.types.Command;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
58 import com.google.gson.Gson;
59 import com.google.gson.GsonBuilder;
60 import com.google.gson.JsonObject;
63 * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
64 * sent to one of the channels.
66 * @author Marcel Verpaalen - Initial contribution
69 public abstract class MiIoAbstractHandler extends BaseThingHandler implements MiIoMessageListener {
70 protected static final int MAX_QUEUE = 5;
71 protected static final Gson GSON = new GsonBuilder().create();
73 protected ScheduledExecutorService miIoScheduler = scheduler;
74 protected @Nullable ScheduledFuture<?> pollingJob;
75 protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
76 protected boolean isIdentified;
78 protected byte[] token = new byte[0];
80 protected @Nullable MiIoBindingConfiguration configuration;
81 protected @Nullable MiIoAsyncCommunication miioCom;
82 protected CloudConnector cloudConnector;
83 protected String cloudServer = "";
86 protected Map<Integer, String> cmds = new ConcurrentHashMap<>();
87 protected Map<String, Object> deviceVariables = new HashMap<>();
88 protected final ExpiringCache<String> network = new ExpiringCache<>(CACHE_EXPIRY_NETWORK, () -> {
89 int ret = sendCommand(MiIoCommand.MIIO_INFO);
95 protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
96 protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
98 private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
99 protected MiIoDatabaseWatchService miIoDatabaseWatchService;
101 public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
102 CloudConnector cloudConnector) {
104 this.miIoDatabaseWatchService = miIoDatabaseWatchService;
105 this.cloudConnector = cloudConnector;
109 public abstract void handleCommand(ChannelUID channelUID, Command command);
111 protected boolean handleCommandsChannels(ChannelUID channelUID, Command command) {
112 if (channelUID.getId().equals(CHANNEL_COMMAND)) {
113 cmds.put(sendCommand(command.toString(), ""), command.toString());
116 if (channelUID.getId().equals(CHANNEL_RPC)) {
117 cmds.put(sendCommand(command.toString(), cloudServer), command.toString());
124 public void initialize() {
125 logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
126 getThing().getThingTypeUID());
128 ScheduledThreadPoolExecutor miIoScheduler = new ScheduledThreadPoolExecutor(3,
129 new NamedThreadFactory(getThing().getUID().getAsString(), true));
130 miIoScheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
131 miIoScheduler.setRemoveOnCancelPolicy(true);
132 this.miIoScheduler = miIoScheduler;
134 final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
135 this.configuration = configuration;
136 if (configuration.host == null || configuration.host.isEmpty()) {
137 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
138 "IP address required. Configure IP address");
141 if (!tokenCheckPass(configuration.token)) {
142 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token");
145 cloudServer = (configuration.cloudServer != null) ? configuration.cloudServer : "";
146 isIdentified = false;
147 miIoScheduler.schedule(this::initializeData, 1, TimeUnit.SECONDS);
148 int pollingPeriod = configuration.refreshInterval;
149 if (pollingPeriod > 0) {
150 pollingJob = miIoScheduler.scheduleWithFixedDelay(() -> {
153 } catch (Exception e) {
154 logger.debug("Unexpected error during refresh.", e);
156 }, 10, pollingPeriod, TimeUnit.SECONDS);
157 logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
159 logger.debug("Polling job disabled. for '{}'", getThing().getUID());
160 miIoScheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
162 updateStatus(ThingStatus.OFFLINE);
165 private boolean tokenCheckPass(@Nullable String tokenSting) {
166 if (tokenSting == null) {
169 switch (tokenSting.length()) {
171 token = tokenSting.getBytes();
174 if (!IGNORED_TOKENS.contains(tokenSting)) {
175 token = Utils.hexStringToByteArray(tokenSting);
181 token = Utils.hexStringToByteArray(MiIoCrypto.decryptToken(Utils.hexStringToByteArray(tokenSting)));
182 logger.debug("IOS token decrypted to {}", Utils.getHex(token));
183 } catch (MiIoCryptoException e) {
184 logger.warn("Could not decrypt token {}{}", tokenSting, e.getMessage());
194 public void dispose() {
195 logger.debug("Disposing Xiaomi Mi IO handler '{}'", getThing().getUID());
196 miIoScheduler.shutdown();
197 final ScheduledFuture<?> pollingJob = this.pollingJob;
198 if (pollingJob != null) {
199 pollingJob.cancel(true);
200 this.pollingJob = null;
202 final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
203 if (miioCom != null) {
204 lastId = miioCom.getId();
205 miioCom.unregisterListener(this);
209 miIoScheduler.shutdownNow();
212 protected int sendCommand(MiIoCommand command) {
213 return sendCommand(command, "[]");
216 protected int sendCommand(MiIoCommand command, String params) {
218 final MiIoAsyncCommunication connection = getConnection();
219 return (connection != null) ? connection.queueCommand(command, params, getCloudServer()) : 0;
220 } catch (MiIoCryptoException | IOException e) {
221 logger.debug("Command {} for {} failed (type: {}): {}", command.toString(), getThing().getUID(),
222 getThing().getThingTypeUID(), e.getLocalizedMessage());
227 protected int sendCommand(String commandString) {
228 return sendCommand(commandString, getCloudServer());
232 * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
234 * [] brackets. This to allow for unimplemented commands to be executed (e.g. get detailed historical cleaning
237 * @param commandString command to be executed
238 * @param cloud server to be used or empty string for direct sending to the device
239 * @return vacuum response
241 protected int sendCommand(String commandString, String cloudServer) {
242 final MiIoAsyncCommunication connection = getConnection();
244 String command = commandString.trim();
246 int sb = command.indexOf("[");
247 int cb = command.indexOf("{");
248 if (Math.max(sb, cb) > 0) {
249 int loc = (Math.min(sb, cb) > 0 ? Math.min(sb, cb) : Math.max(sb, cb));
250 param = command.substring(loc).trim();
251 command = command.substring(0, loc).trim();
253 return (connection != null) ? connection.queueCommand(command, param, cloudServer) : 0;
254 } catch (MiIoCryptoException | IOException e) {
255 disconnected(e.getMessage());
260 String getCloudServer() {
261 // This can be improved in the future with additional / more advanced options like e.g. directFirst which would
262 // use direct communications and in case of failures fall back to cloud communication. For now we keep it
263 // simple and only have the option for cloud or direct.
264 final MiIoBindingConfiguration configuration = this.configuration;
265 if (configuration != null && configuration.communication != null) {
266 return configuration.communication.equals("cloud") ? cloudServer : "";
271 protected boolean skipUpdate() {
272 final MiIoAsyncCommunication miioCom = this.miioCom;
273 if (!hasConnection() || miioCom == null) {
274 logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
277 if (getThing().getStatusInfo().getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)) {
278 logger.debug("Skipping periodic update for '{}'. Thing Status {}", getThing().getUID().toString(),
279 getThing().getStatusInfo().getStatusDetail());
280 sendCommand(MiIoCommand.MIIO_INFO);
283 if (miioCom.getQueueLength() > MAX_QUEUE) {
284 logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
285 miioCom.getQueueLength());
291 protected abstract void updateData();
293 protected boolean updateNetwork(JsonObject networkData) {
295 updateState(CHANNEL_SSID, new StringType(networkData.getAsJsonObject("ap").get("ssid").getAsString()));
296 updateState(CHANNEL_BSSID, new StringType(networkData.getAsJsonObject("ap").get("bssid").getAsString()));
297 if (networkData.getAsJsonObject("ap").get("rssi") != null) {
298 updateState(CHANNEL_RSSI, new DecimalType(networkData.getAsJsonObject("ap").get("rssi").getAsLong()));
299 } else if (networkData.getAsJsonObject("ap").get("wifi_rssi") != null) {
300 updateState(CHANNEL_RSSI,
301 new DecimalType(networkData.getAsJsonObject("ap").get("wifi_rssi").getAsLong()));
303 logger.debug("No RSSI info in response");
305 updateState(CHANNEL_LIFE, new DecimalType(networkData.get("life").getAsLong()));
307 } catch (Exception e) {
308 logger.debug("Could not parse network response: {}", networkData, e);
313 protected boolean hasConnection() {
314 return getConnection() != null;
317 protected void disconnectedNoResponse() {
318 disconnected("No Response from device");
321 protected void disconnected(@Nullable String message) {
322 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
323 message != null ? message : "");
324 final MiIoAsyncCommunication miioCom = this.miioCom;
325 if (miioCom != null) {
326 lastId = miioCom.getId();
331 protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
332 if (miioCom != null) {
335 final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
336 if (configuration.host == null || configuration.host.isEmpty()) {
340 String deviceId = configuration.deviceId;
342 if (deviceId != null && deviceId.length() == 8 && tokenCheckPass(configuration.token)) {
343 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
344 Utils.hexStringToByteArray(deviceId), lastId, configuration.timeout, cloudConnector);
345 if (getCloudServer().isBlank()) {
346 logger.debug("Ping Mi device {} at {}", deviceId, configuration.host);
347 Message miIoResponse = miioCom.sendPing(configuration.host);
348 if (miIoResponse != null) {
349 logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
350 Utils.getHex(miIoResponse.getDeviceId()), configuration.host,
351 miIoResponse.getTimestamp(), LocalDateTime.now(), miioCom.getTimeDelta());
352 miioCom.registerListener(this);
353 this.miioCom = miioCom;
359 miioCom.registerListener(this);
360 this.miioCom = miioCom;
364 logger.debug("No device ID defined. Retrieving Mi device ID");
365 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
366 new byte[0], lastId, configuration.timeout, cloudConnector);
367 Message miIoResponse = miioCom.sendPing(configuration.host);
368 if (miIoResponse != null) {
369 logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
370 Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
371 LocalDateTime.now(), miioCom.getTimeDelta());
372 deviceId = Utils.getHex(miIoResponse.getDeviceId());
373 logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}", deviceId,
374 configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
375 miioCom.getTimeDelta());
376 miioCom.setDeviceId(miIoResponse.getDeviceId());
377 logger.debug("Using retrieved Mi device ID: {}", deviceId);
378 updateDeviceIdConfig(deviceId);
379 miioCom.registerListener(this);
380 this.miioCom = miioCom;
386 logger.debug("Ping response from device {} at {} FAILED", configuration.deviceId, configuration.host);
387 disconnectedNoResponse();
389 } catch (IOException e) {
390 logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
391 disconnected(e.getMessage());
396 private void updateDeviceIdConfig(String deviceId) {
397 if (!deviceId.isEmpty()) {
398 updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
399 Configuration config = editConfiguration();
400 config.put(PROPERTY_DID, deviceId);
401 updateConfiguration(config);
403 logger.debug("Could not update config with device ID: {}", deviceId);
407 protected boolean initializeData() {
408 this.miioCom = getConnection();
412 protected void refreshNetwork() {
416 protected void defineDeviceType(JsonObject miioInfo) {
417 updateProperties(miioInfo);
418 isIdentified = updateThingType(miioInfo);
421 private void updateProperties(JsonObject miioInfo) {
422 final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
423 Map<String, String> properties = editProperties();
424 if (info.model != null) {
425 properties.put(Thing.PROPERTY_MODEL_ID, info.model);
427 if (info.fwVer != null) {
428 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
430 if (info.hwVer != null) {
431 properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
433 if (info.wifiFwVer != null) {
434 properties.put("wifiFirmware", info.wifiFwVer);
436 if (info.mcuFwVer != null) {
437 properties.put("mcuFirmware", info.mcuFwVer);
439 deviceVariables.putAll(properties);
440 updateProperties(properties);
443 protected boolean updateThingType(JsonObject miioInfo) {
444 MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
445 String model = miioInfo.get("model").getAsString();
446 miDevice = MiIoDevices.getType(model);
447 if (configuration.model == null || configuration.model.isEmpty()) {
448 Configuration config = editConfiguration();
449 config.put(PROPERTY_MODEL, model);
450 updateConfiguration(config);
451 configuration = getConfigAs(MiIoBindingConfiguration.class);
453 if (!configuration.model.equals(model)) {
454 logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
455 configuration.model);
457 if (miDevice.getThingType().equals(getThing().getThingTypeUID())
458 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
459 && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
460 logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
461 miDevice.getThingType().toString());
464 if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
465 || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
469 "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
470 miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
471 miDevice.getThingType().toString());
479 * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
482 * @param modelId String with the model id
484 private void changeType(final String modelId) {
485 final ScheduledFuture<?> pollingJob = this.pollingJob;
486 if (pollingJob != null) {
487 pollingJob.cancel(true);
488 this.pollingJob = null;
490 miIoScheduler.schedule(() -> {
491 String label = getThing().getLabel();
492 if (label == null || label.startsWith("Xiaomi Mi Device")) {
493 ThingBuilder thingBuilder = editThing();
494 thingBuilder.withLabel(miDevice.getDescription());
495 updateThing(thingBuilder.build());
497 logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
498 modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
499 miDevice.getThingType().toString());
500 ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
501 if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
502 && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
503 thingTypeUID = THING_TYPE_BASIC;
505 changeThingType(thingTypeUID, getConfig());
506 }, 10, TimeUnit.SECONDS);
510 public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
511 updateStatus(status, statusDetail);
515 public void onMessageReceived(MiIoSendCommand response) {
516 logger.debug("Received response for {} type: {}, result: {}, fullresponse: {}", getThing().getUID().getId(),
517 response.getCommand(), response.getResult(), response.getResponse());
518 if (response.isError()) {
519 logger.debug("Error received: {}", response.getResponse().get("error"));
520 if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
521 network.invalidateValue();
526 switch (response.getCommand()) {
529 defineDeviceType(response.getResult().getAsJsonObject());
531 updateNetwork(response.getResult().getAsJsonObject());
536 if (cmds.containsKey(response.getId())) {
537 if (response.getCloudServer().isBlank()) {
538 updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
540 updateState(CHANNEL_RPC, new StringType(response.getResponse().toString()));
542 cmds.remove(response.getId());
544 } catch (Exception e) {
545 logger.debug("Error while handing message {}", response.getResponse(), e);