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.MiIoInfoApDTO;
36 import org.openhab.binding.miio.internal.MiIoInfoDTO;
37 import org.openhab.binding.miio.internal.MiIoMessageListener;
38 import org.openhab.binding.miio.internal.MiIoSendCommand;
39 import org.openhab.binding.miio.internal.Utils;
40 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
41 import org.openhab.binding.miio.internal.cloud.CloudConnector;
42 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
43 import org.openhab.core.cache.ExpiringCache;
44 import org.openhab.core.common.NamedThreadFactory;
45 import org.openhab.core.config.core.Configuration;
46 import org.openhab.core.library.types.DecimalType;
47 import org.openhab.core.library.types.StringType;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.thing.ThingTypeUID;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.thing.binding.builder.ThingBuilder;
55 import org.openhab.core.types.Command;
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.JsonObject;
62 import com.google.gson.JsonSyntaxException;
65 * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
66 * sent to one of the channels.
68 * @author Marcel Verpaalen - Initial contribution
71 public abstract class MiIoAbstractHandler extends BaseThingHandler implements MiIoMessageListener {
72 protected static final int MAX_QUEUE = 5;
73 protected static final Gson GSON = new GsonBuilder().create();
75 protected ScheduledExecutorService miIoScheduler = scheduler;
76 protected @Nullable ScheduledFuture<?> pollingJob;
77 protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
78 protected boolean isIdentified;
80 protected byte[] token = new byte[0];
82 protected @Nullable MiIoBindingConfiguration configuration;
83 protected @Nullable MiIoAsyncCommunication miioCom;
84 protected CloudConnector cloudConnector;
85 protected String cloudServer = "";
88 protected Map<Integer, String> cmds = new ConcurrentHashMap<>();
89 protected Map<String, Object> deviceVariables = new HashMap<>();
90 protected final ExpiringCache<String> network = new ExpiringCache<>(CACHE_EXPIRY_NETWORK, () -> {
91 int ret = sendCommand(MiIoCommand.MIIO_INFO);
97 protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
98 protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
100 private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
101 protected MiIoDatabaseWatchService miIoDatabaseWatchService;
103 public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
104 CloudConnector cloudConnector) {
106 this.miIoDatabaseWatchService = miIoDatabaseWatchService;
107 this.cloudConnector = cloudConnector;
111 public abstract void handleCommand(ChannelUID channelUID, Command command);
113 protected boolean handleCommandsChannels(ChannelUID channelUID, Command command) {
114 if (channelUID.getId().equals(CHANNEL_COMMAND)) {
115 cmds.put(sendCommand(command.toString()), channelUID.getId());
118 if (channelUID.getId().equals(CHANNEL_RPC)) {
119 cmds.put(sendCommand(command.toString(), cloudServer), channelUID.getId());
126 public void initialize() {
127 logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
128 getThing().getThingTypeUID());
130 ScheduledThreadPoolExecutor miIoScheduler = new ScheduledThreadPoolExecutor(3,
131 new NamedThreadFactory(getThing().getUID().getAsString(), true));
132 miIoScheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
133 miIoScheduler.setRemoveOnCancelPolicy(true);
134 this.miIoScheduler = miIoScheduler;
136 final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
137 this.configuration = configuration;
138 if (configuration.host.isEmpty()) {
139 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
140 "IP address required. Configure IP address");
143 if (!tokenCheckPass(configuration.token)) {
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token");
147 this.cloudServer = configuration.cloudServer;
148 isIdentified = false;
149 miIoScheduler.schedule(this::initializeData, 1, TimeUnit.SECONDS);
150 int pollingPeriod = configuration.refreshInterval;
151 if (pollingPeriod > 0) {
152 pollingJob = miIoScheduler.scheduleWithFixedDelay(() -> {
155 } catch (Exception e) {
156 logger.debug("Unexpected error during refresh.", e);
158 }, 10, pollingPeriod, TimeUnit.SECONDS);
159 logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
161 logger.debug("Polling job disabled. for '{}'", getThing().getUID());
162 miIoScheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
164 updateStatus(ThingStatus.OFFLINE);
167 private boolean tokenCheckPass(@Nullable String tokenSting) {
168 if (tokenSting == null) {
171 switch (tokenSting.length()) {
173 token = tokenSting.getBytes();
176 if (!IGNORED_TOKENS.contains(tokenSting)) {
177 token = Utils.hexStringToByteArray(tokenSting);
183 token = Utils.hexStringToByteArray(MiIoCrypto.decryptToken(Utils.hexStringToByteArray(tokenSting)));
184 logger.debug("IOS token decrypted to {}", Utils.getHex(token));
185 } catch (MiIoCryptoException e) {
186 logger.warn("Could not decrypt token {}{}", tokenSting, e.getMessage());
196 public void dispose() {
197 logger.debug("Disposing Xiaomi Mi IO handler '{}'", getThing().getUID());
198 miIoScheduler.shutdown();
199 final ScheduledFuture<?> pollingJob = this.pollingJob;
200 if (pollingJob != null) {
201 pollingJob.cancel(true);
202 this.pollingJob = null;
204 final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
205 if (miioCom != null) {
206 lastId = miioCom.getId();
207 miioCom.unregisterListener(this);
211 miIoScheduler.shutdownNow();
214 protected int sendCommand(MiIoCommand command) {
215 return sendCommand(command, "[]");
218 protected int sendCommand(MiIoCommand command, String params) {
220 final MiIoAsyncCommunication connection = getConnection();
221 return (connection != null) ? connection.queueCommand(command, params, getCloudServer()) : 0;
222 } catch (MiIoCryptoException | IOException e) {
223 logger.debug("Command {} for {} failed (type: {}): {}", command.toString(), getThing().getUID(),
224 getThing().getThingTypeUID(), e.getLocalizedMessage());
229 protected int sendCommand(String commandString) {
230 return sendCommand(commandString, getCloudServer());
234 * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
236 * [] brackets. This to allow for unimplemented commands to be executed (e.g. get detailed historical cleaning
239 * @param commandString command to be executed
240 * @param cloud server to be used or empty string for direct sending to the device
241 * @return vacuum response
243 protected int sendCommand(String commandString, String cloudServer) {
244 final MiIoAsyncCommunication connection = getConnection();
246 String command = commandString.trim();
248 int sb = command.indexOf("[");
249 int cb = command.indexOf("{");
250 if (Math.max(sb, cb) > 0) {
251 int loc = (Math.min(sb, cb) > 0 ? Math.min(sb, cb) : Math.max(sb, cb));
252 param = command.substring(loc).trim();
253 command = command.substring(0, loc).trim();
255 return (connection != null) ? connection.queueCommand(command, param, cloudServer) : 0;
256 } catch (MiIoCryptoException | IOException e) {
257 disconnected(e.getMessage());
262 String getCloudServer() {
263 // This can be improved in the future with additional / more advanced options like e.g. directFirst which would
264 // use direct communications and in case of failures fall back to cloud communication. For now we keep it
265 // simple and only have the option for cloud or direct.
266 final MiIoBindingConfiguration configuration = this.configuration;
267 if (configuration != null) {
268 return configuration.communication.equals("cloud") ? cloudServer : "";
273 protected boolean skipUpdate() {
274 final MiIoAsyncCommunication miioCom = this.miioCom;
275 if (!hasConnection() || miioCom == null) {
276 logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
279 if (getThing().getStatusInfo().getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)) {
280 logger.debug("Skipping periodic update for '{}'. Thing Status {}", getThing().getUID().toString(),
281 getThing().getStatusInfo().getStatusDetail());
282 sendCommand(MiIoCommand.MIIO_INFO);
285 if (miioCom.getQueueLength() > MAX_QUEUE) {
286 logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
287 miioCom.getQueueLength());
293 protected abstract void updateData();
295 protected boolean updateNetwork(JsonObject networkData) {
297 final MiIoInfoDTO miioInfo = GSON.fromJson(networkData, MiIoInfoDTO.class);
298 final MiIoInfoApDTO ap = miioInfo != null ? miioInfo.ap : null;
299 if (miioInfo != null && ap != null) {
300 if (ap.getSsid() != null) {
301 updateState(CHANNEL_SSID, new StringType(ap.getSsid()));
303 if (ap.getBssid() != null) {
304 updateState(CHANNEL_BSSID, new StringType(ap.getBssid()));
306 if (ap.getRssi() != null) {
307 updateState(CHANNEL_RSSI, new DecimalType(ap.getRssi()));
308 } else if (ap.getWifiRssi() != null) {
309 updateState(CHANNEL_RSSI, new DecimalType(ap.getWifiRssi()));
311 logger.debug("No RSSI info in response");
313 if (miioInfo.life != null) {
314 updateState(CHANNEL_LIFE, new DecimalType(miioInfo.life));
318 } catch (NumberFormatException e) {
319 logger.debug("Could not parse number in network response: {}", networkData);
320 } catch (JsonSyntaxException e) {
321 logger.debug("Could not parse network response: {}", networkData, e);
326 protected boolean hasConnection() {
327 return getConnection() != null;
330 protected void disconnectedNoResponse() {
331 disconnected("No Response from device");
334 protected void disconnected(@Nullable String message) {
335 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
336 message != null ? message : "");
337 final MiIoAsyncCommunication miioCom = this.miioCom;
338 if (miioCom != null) {
339 lastId = miioCom.getId();
344 protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
345 if (miioCom != null) {
348 final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
349 if (configuration.host.isBlank()) {
353 String deviceId = configuration.deviceId;
354 if (deviceId.length() == 8 && deviceId.matches("^.*[a-zA-Z]+.*$")) {
356 "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");
360 if (!deviceId.isBlank() && tokenCheckPass(configuration.token)) {
361 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token, deviceId,
362 lastId, configuration.timeout, cloudConnector);
363 if (getCloudServer().isBlank()) {
364 logger.debug("Ping Mi deviceId '{}' at {}", deviceId, configuration.host);
365 Message miIoResponse = miioCom.sendPing(configuration.host);
366 if (miIoResponse != null) {
367 logger.debug("Ping response from deviceId '{}' at {}. Time stamp: {}, OH time {}, delta {}",
368 Utils.fromHEX(Utils.getHex(miIoResponse.getDeviceId())), configuration.host,
369 miIoResponse.getTimestamp(), LocalDateTime.now(), miioCom.getTimeDelta());
370 miioCom.registerListener(this);
371 this.miioCom = miioCom;
377 miioCom.registerListener(this);
378 this.miioCom = miioCom;
382 logger.debug("No deviceId defined. Retrieving Mi deviceId");
383 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token, "", lastId,
384 configuration.timeout, cloudConnector);
385 Message miIoResponse = miioCom.sendPing(configuration.host);
386 if (miIoResponse != null) {
387 deviceId = Utils.fromHEX(Utils.getHex(miIoResponse.getDeviceId()));
388 logger.debug("Ping response from deviceId '{}' at {}. Time stamp: {}, OH time {}, delta {}",
389 deviceId, configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
390 miioCom.getTimeDelta());
391 miioCom.setDeviceId(deviceId);
392 logger.debug("Using retrieved Mi deviceId: {}", deviceId);
393 updateDeviceIdConfig(deviceId);
394 miioCom.registerListener(this);
395 this.miioCom = miioCom;
401 logger.debug("Ping response from deviceId '{}' at {} FAILED", configuration.deviceId, configuration.host);
402 disconnectedNoResponse();
404 } catch (IOException e) {
405 logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
406 disconnected(e.getMessage());
411 private void updateDeviceIdConfig(String deviceId) {
412 if (!deviceId.isEmpty()) {
413 updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
414 Configuration config = editConfiguration();
415 config.put(PROPERTY_DID, deviceId);
416 updateConfiguration(config);
418 logger.debug("Could not update config with deviceId: {}", deviceId);
422 protected boolean initializeData() {
423 this.miioCom = getConnection();
427 protected void refreshNetwork() {
431 protected void defineDeviceType(JsonObject miioInfo) {
432 updateProperties(miioInfo);
433 isIdentified = updateThingType(miioInfo);
436 private void updateProperties(JsonObject miioInfo) {
437 final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
441 Map<String, String> properties = editProperties();
442 if (info.model != null) {
443 properties.put(Thing.PROPERTY_MODEL_ID, info.model);
445 if (info.fwVer != null) {
446 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
448 if (info.hwVer != null) {
449 properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
451 if (info.wifiFwVer != null) {
452 properties.put("wifiFirmware", info.wifiFwVer);
454 if (info.mcuFwVer != null) {
455 properties.put("mcuFirmware", info.mcuFwVer);
457 deviceVariables.putAll(properties);
458 updateProperties(properties);
461 protected boolean updateThingType(JsonObject miioInfo) {
462 MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
463 String model = miioInfo.get("model").getAsString();
464 miDevice = MiIoDevices.getType(model);
465 if (configuration.model.isEmpty()) {
466 Configuration config = editConfiguration();
467 config.put(PROPERTY_MODEL, model);
468 updateConfiguration(config);
469 configuration = getConfigAs(MiIoBindingConfiguration.class);
471 if (!configuration.model.equals(model)) {
472 logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
473 configuration.model);
475 if (miDevice.getThingType().equals(getThing().getThingTypeUID())
476 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
477 && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
478 logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
479 miDevice.getThingType().toString());
482 if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
483 || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
487 "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
488 miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
489 miDevice.getThingType().toString());
497 * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
500 * @param modelId String with the model id
502 private void changeType(final String modelId) {
503 final ScheduledFuture<?> pollingJob = this.pollingJob;
504 if (pollingJob != null) {
505 pollingJob.cancel(true);
506 this.pollingJob = null;
508 miIoScheduler.schedule(() -> {
509 String label = getThing().getLabel();
510 if (label == null || label.startsWith("Xiaomi Mi Device")) {
511 ThingBuilder thingBuilder = editThing();
512 thingBuilder.withLabel(miDevice.getDescription());
513 updateThing(thingBuilder.build());
515 logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
516 modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
517 miDevice.getThingType().toString());
518 ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
519 if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
520 && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
521 thingTypeUID = THING_TYPE_BASIC;
523 changeThingType(thingTypeUID, getConfig());
524 }, 10, TimeUnit.SECONDS);
528 public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
529 updateStatus(status, statusDetail);
533 public void onMessageReceived(MiIoSendCommand response) {
534 logger.debug("Received response for device {} type: {}, result: {}, fullresponse: {}",
535 getThing().getUID().getId(), response.getCommand(), response.getResult(), response.getResponse());
536 if (response.isError()) {
537 logger.debug("Error received for command '{}': {}.", response.getCommandString(),
538 response.getResponse().get("error"));
539 if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
540 network.invalidateValue();
545 switch (response.getCommand()) {
548 defineDeviceType(response.getResult().getAsJsonObject());
550 updateNetwork(response.getResult().getAsJsonObject());
555 if (cmds.containsKey(response.getId())) {
556 String channel = cmds.get(response.getId());
557 if (channel != null && (CHANNEL_COMMAND.contentEquals(channel) || CHANNEL_RPC.contentEquals(channel))) {
558 updateState(channel, new StringType(response.getResponse().toString()));
559 cmds.remove(response.getId());
562 } catch (Exception e) {
563 logger.debug("Error while handing message {}", response.getResponse(), e);