]> git.basschouten.com Git - openhab-addons.git/blob
b38208451c01c01d5d9fd7d2263689e256045487
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.miio.internal.handler;
14
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
16
17 import java.io.IOException;
18 import java.time.LocalDateTime;
19 import java.util.HashMap;
20 import java.util.Map;
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;
26
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;
57
58 import com.google.gson.Gson;
59 import com.google.gson.GsonBuilder;
60 import com.google.gson.JsonObject;
61
62 /**
63  * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
64  * sent to one of the channels.
65  *
66  * @author Marcel Verpaalen - Initial contribution
67  */
68 @NonNullByDefault
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();
72
73     protected ScheduledExecutorService miIoScheduler = scheduler;
74     protected @Nullable ScheduledFuture<?> pollingJob;
75     protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
76     protected boolean isIdentified;
77
78     protected byte[] token = new byte[0];
79
80     protected @Nullable MiIoBindingConfiguration configuration;
81     protected @Nullable MiIoAsyncCommunication miioCom;
82     protected CloudConnector cloudConnector;
83     protected String cloudServer = "";
84     protected int lastId;
85
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);
90         if (ret != 0) {
91             return "id:" + ret;
92         }
93         return "failed";
94     });;
95     protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
96     protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
97
98     private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
99     protected MiIoDatabaseWatchService miIoDatabaseWatchService;
100
101     public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
102             CloudConnector cloudConnector) {
103         super(thing);
104         this.miIoDatabaseWatchService = miIoDatabaseWatchService;
105         this.cloudConnector = cloudConnector;
106     }
107
108     @Override
109     public abstract void handleCommand(ChannelUID channelUID, Command command);
110
111     protected boolean handleCommandsChannels(ChannelUID channelUID, Command command) {
112         if (channelUID.getId().equals(CHANNEL_COMMAND)) {
113             cmds.put(sendCommand(command.toString(), ""), command.toString());
114             return true;
115         }
116         if (channelUID.getId().equals(CHANNEL_RPC)) {
117             cmds.put(sendCommand(command.toString(), cloudServer), command.toString());
118             return true;
119         }
120         return false;
121     }
122
123     @Override
124     public void initialize() {
125         logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
126                 getThing().getThingTypeUID());
127
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;
133
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");
139             return;
140         }
141         if (!tokenCheckPass(configuration.token)) {
142             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token");
143             return;
144         }
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(() -> {
151                 try {
152                     updateData();
153                 } catch (Exception e) {
154                     logger.debug("Unexpected error during refresh.", e);
155                 }
156             }, 10, pollingPeriod, TimeUnit.SECONDS);
157             logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
158         } else {
159             logger.debug("Polling job disabled. for '{}'", getThing().getUID());
160             miIoScheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
161         }
162         updateStatus(ThingStatus.OFFLINE);
163     }
164
165     private boolean tokenCheckPass(@Nullable String tokenSting) {
166         if (tokenSting == null) {
167             return false;
168         }
169         switch (tokenSting.length()) {
170             case 16:
171                 token = tokenSting.getBytes();
172                 return true;
173             case 32:
174                 if (!IGNORED_TOKENS.contains(tokenSting)) {
175                     token = Utils.hexStringToByteArray(tokenSting);
176                     return true;
177                 }
178                 return false;
179             case 96:
180                 try {
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());
185                     return false;
186                 }
187                 return true;
188             default:
189                 return false;
190         }
191     }
192
193     @Override
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;
201         }
202         final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
203         if (miioCom != null) {
204             lastId = miioCom.getId();
205             miioCom.unregisterListener(this);
206             miioCom.close();
207             this.miioCom = null;
208         }
209         miIoScheduler.shutdownNow();
210     }
211
212     protected int sendCommand(MiIoCommand command) {
213         return sendCommand(command, "[]");
214     }
215
216     protected int sendCommand(MiIoCommand command, String params) {
217         try {
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());
223         }
224         return 0;
225     }
226
227     protected int sendCommand(String commandString) {
228         return sendCommand(commandString, getCloudServer());
229     }
230
231     /**
232      * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
233      * between
234      * [] brackets. This to allow for unimplemented commands to be executed (e.g. get detailed historical cleaning
235      * records)
236      *
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
240      */
241     protected int sendCommand(String commandString, String cloudServer) {
242         final MiIoAsyncCommunication connection = getConnection();
243         try {
244             String command = commandString.trim();
245             String param = "[]";
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();
252             }
253             return (connection != null) ? connection.queueCommand(command, param, cloudServer) : 0;
254         } catch (MiIoCryptoException | IOException e) {
255             disconnected(e.getMessage());
256         }
257         return 0;
258     }
259
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 : "";
267         }
268         return "";
269     }
270
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());
275             return true;
276         }
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);
281             return true;
282         }
283         if (miioCom.getQueueLength() > MAX_QUEUE) {
284             logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
285                     miioCom.getQueueLength());
286             return true;
287         }
288         return false;
289     }
290
291     protected abstract void updateData();
292
293     protected boolean updateNetwork(JsonObject networkData) {
294         try {
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()));
302             } else {
303                 logger.debug("No RSSI info in response");
304             }
305             updateState(CHANNEL_LIFE, new DecimalType(networkData.get("life").getAsLong()));
306             return true;
307         } catch (Exception e) {
308             logger.debug("Could not parse network response: {}", networkData, e);
309         }
310         return false;
311     }
312
313     protected boolean hasConnection() {
314         return getConnection() != null;
315     }
316
317     protected void disconnectedNoResponse() {
318         disconnected("No Response from device");
319     }
320
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();
327             lastId += 10;
328         }
329     }
330
331     protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
332         if (miioCom != null) {
333             return miioCom;
334         }
335         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
336         if (configuration.host == null || configuration.host.isEmpty()) {
337             return null;
338         }
339         @Nullable
340         String deviceId = configuration.deviceId;
341         try {
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;
354                         return miioCom;
355                     } else {
356                         miioCom.close();
357                     }
358                 } else {
359                     miioCom.registerListener(this);
360                     this.miioCom = miioCom;
361                     return miioCom;
362                 }
363             } else {
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;
381                     return miioCom;
382                 } else {
383                     miioCom.close();
384                 }
385             }
386             logger.debug("Ping response from device {} at {} FAILED", configuration.deviceId, configuration.host);
387             disconnectedNoResponse();
388             return null;
389         } catch (IOException e) {
390             logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
391             disconnected(e.getMessage());
392             return null;
393         }
394     }
395
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);
402         } else {
403             logger.debug("Could not update config with device ID: {}", deviceId);
404         }
405     }
406
407     protected boolean initializeData() {
408         this.miioCom = getConnection();
409         return true;
410     }
411
412     protected void refreshNetwork() {
413         network.getValue();
414     }
415
416     protected void defineDeviceType(JsonObject miioInfo) {
417         updateProperties(miioInfo);
418         isIdentified = updateThingType(miioInfo);
419     }
420
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);
426         }
427         if (info.fwVer != null) {
428             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
429         }
430         if (info.hwVer != null) {
431             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
432         }
433         if (info.wifiFwVer != null) {
434             properties.put("wifiFirmware", info.wifiFwVer);
435         }
436         if (info.mcuFwVer != null) {
437             properties.put("mcuFirmware", info.mcuFwVer);
438         }
439         deviceVariables.putAll(properties);
440         updateProperties(properties);
441     }
442
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);
452         }
453         if (!configuration.model.equals(model)) {
454             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
455                     configuration.model);
456         }
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());
462             return true;
463         } else {
464             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
465                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
466                 changeType(model);
467             } else {
468                 logger.info(
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());
472                 return true;
473             }
474         }
475         return false;
476     }
477
478     /**
479      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
480      * the device.
481      *
482      * @param modelId String with the model id
483      */
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;
489         }
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());
496             }
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;
504             }
505             changeThingType(thingTypeUID, getConfig());
506         }, 10, TimeUnit.SECONDS);
507     }
508
509     @Override
510     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
511         updateStatus(status, statusDetail);
512     }
513
514     @Override
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();
522             }
523             return;
524         }
525         try {
526             switch (response.getCommand()) {
527                 case MIIO_INFO:
528                     if (!isIdentified) {
529                         defineDeviceType(response.getResult().getAsJsonObject());
530                     }
531                     updateNetwork(response.getResult().getAsJsonObject());
532                     break;
533                 default:
534                     break;
535             }
536             if (cmds.containsKey(response.getId())) {
537                 if (response.getCloudServer().isBlank()) {
538                     updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
539                 } else {
540                     updateState(CHANNEL_RPC, new StringType(response.getResponse().toString()));
541                 }
542                 cmds.remove(response.getId());
543             }
544         } catch (Exception e) {
545             logger.debug("Error while handing message {}", response.getResponse(), e);
546         }
547     }
548 }