]> git.basschouten.com Git - openhab-addons.git/blob
21df3ea3625ef4a13667286117e0c0fc0ca14430
[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.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;
58
59 import com.google.gson.Gson;
60 import com.google.gson.GsonBuilder;
61 import com.google.gson.JsonObject;
62 import com.google.gson.JsonSyntaxException;
63
64 /**
65  * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
66  * sent to one of the channels.
67  *
68  * @author Marcel Verpaalen - Initial contribution
69  */
70 @NonNullByDefault
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();
74
75     protected ScheduledExecutorService miIoScheduler = scheduler;
76     protected @Nullable ScheduledFuture<?> pollingJob;
77     protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
78     protected boolean isIdentified;
79
80     protected byte[] token = new byte[0];
81
82     protected @Nullable MiIoBindingConfiguration configuration;
83     protected @Nullable MiIoAsyncCommunication miioCom;
84     protected CloudConnector cloudConnector;
85     protected String cloudServer = "";
86     protected int lastId;
87
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);
92         if (ret != 0) {
93             return "id:" + ret;
94         }
95         return "failed";
96     });;
97     protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
98     protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
99
100     private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
101     protected MiIoDatabaseWatchService miIoDatabaseWatchService;
102
103     public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
104             CloudConnector cloudConnector) {
105         super(thing);
106         this.miIoDatabaseWatchService = miIoDatabaseWatchService;
107         this.cloudConnector = cloudConnector;
108     }
109
110     @Override
111     public abstract void handleCommand(ChannelUID channelUID, Command command);
112
113     protected boolean handleCommandsChannels(ChannelUID channelUID, Command command) {
114         if (channelUID.getId().equals(CHANNEL_COMMAND)) {
115             cmds.put(sendCommand(command.toString(), ""), command.toString());
116             return true;
117         }
118         if (channelUID.getId().equals(CHANNEL_RPC)) {
119             cmds.put(sendCommand(command.toString(), cloudServer), command.toString());
120             return true;
121         }
122         return false;
123     }
124
125     @Override
126     public void initialize() {
127         logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
128                 getThing().getThingTypeUID());
129
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;
135
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");
141             return;
142         }
143         if (!tokenCheckPass(configuration.token)) {
144             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token");
145             return;
146         }
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(() -> {
153                 try {
154                     updateData();
155                 } catch (Exception e) {
156                     logger.debug("Unexpected error during refresh.", e);
157                 }
158             }, 10, pollingPeriod, TimeUnit.SECONDS);
159             logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
160         } else {
161             logger.debug("Polling job disabled. for '{}'", getThing().getUID());
162             miIoScheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
163         }
164         updateStatus(ThingStatus.OFFLINE);
165     }
166
167     private boolean tokenCheckPass(@Nullable String tokenSting) {
168         if (tokenSting == null) {
169             return false;
170         }
171         switch (tokenSting.length()) {
172             case 16:
173                 token = tokenSting.getBytes();
174                 return true;
175             case 32:
176                 if (!IGNORED_TOKENS.contains(tokenSting)) {
177                     token = Utils.hexStringToByteArray(tokenSting);
178                     return true;
179                 }
180                 return false;
181             case 96:
182                 try {
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());
187                     return false;
188                 }
189                 return true;
190             default:
191                 return false;
192         }
193     }
194
195     @Override
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;
203         }
204         final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
205         if (miioCom != null) {
206             lastId = miioCom.getId();
207             miioCom.unregisterListener(this);
208             miioCom.close();
209             this.miioCom = null;
210         }
211         miIoScheduler.shutdownNow();
212     }
213
214     protected int sendCommand(MiIoCommand command) {
215         return sendCommand(command, "[]");
216     }
217
218     protected int sendCommand(MiIoCommand command, String params) {
219         try {
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());
225         }
226         return 0;
227     }
228
229     protected int sendCommand(String commandString) {
230         return sendCommand(commandString, getCloudServer());
231     }
232
233     /**
234      * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
235      * between
236      * [] brackets. This to allow for unimplemented commands to be executed (e.g. get detailed historical cleaning
237      * records)
238      *
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
242      */
243     protected int sendCommand(String commandString, String cloudServer) {
244         final MiIoAsyncCommunication connection = getConnection();
245         try {
246             String command = commandString.trim();
247             String param = "[]";
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();
254             }
255             return (connection != null) ? connection.queueCommand(command, param, cloudServer) : 0;
256         } catch (MiIoCryptoException | IOException e) {
257             disconnected(e.getMessage());
258         }
259         return 0;
260     }
261
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 : "";
269         }
270         return "";
271     }
272
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());
277             return true;
278         }
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);
283             return true;
284         }
285         if (miioCom.getQueueLength() > MAX_QUEUE) {
286             logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
287                     miioCom.getQueueLength());
288             return true;
289         }
290         return false;
291     }
292
293     protected abstract void updateData();
294
295     protected boolean updateNetwork(JsonObject networkData) {
296         try {
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()));
302                 }
303                 if (ap.getBssid() != null) {
304                     updateState(CHANNEL_BSSID, new StringType(ap.getBssid()));
305                 }
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()));
310                 } else {
311                     logger.debug("No RSSI info in response");
312                 }
313                 if (miioInfo.life != null) {
314                     updateState(CHANNEL_LIFE, new DecimalType(miioInfo.life));
315                 }
316             }
317             return true;
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);
322         }
323         return false;
324     }
325
326     protected boolean hasConnection() {
327         return getConnection() != null;
328     }
329
330     protected void disconnectedNoResponse() {
331         disconnected("No Response from device");
332     }
333
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();
340             lastId += 10;
341         }
342     }
343
344     protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
345         if (miioCom != null) {
346             return miioCom;
347         }
348         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
349         if (configuration.host.isBlank()) {
350             return null;
351         }
352         @Nullable
353         String deviceId = configuration.deviceId;
354         try {
355             if (deviceId.length() == 8 && tokenCheckPass(configuration.token)) {
356                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
357                         Utils.hexStringToByteArray(deviceId), lastId, configuration.timeout, cloudConnector);
358                 if (getCloudServer().isBlank()) {
359                     logger.debug("Ping Mi device {} at {}", deviceId, configuration.host);
360                     Message miIoResponse = miioCom.sendPing(configuration.host);
361                     if (miIoResponse != null) {
362                         logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
363                                 Utils.getHex(miIoResponse.getDeviceId()), configuration.host,
364                                 miIoResponse.getTimestamp(), LocalDateTime.now(), miioCom.getTimeDelta());
365                         miioCom.registerListener(this);
366                         this.miioCom = miioCom;
367                         return miioCom;
368                     } else {
369                         miioCom.close();
370                     }
371                 } else {
372                     miioCom.registerListener(this);
373                     this.miioCom = miioCom;
374                     return miioCom;
375                 }
376             } else {
377                 logger.debug("No device ID defined. Retrieving Mi device ID");
378                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
379                         new byte[0], lastId, configuration.timeout, cloudConnector);
380                 Message miIoResponse = miioCom.sendPing(configuration.host);
381                 if (miIoResponse != null) {
382                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
383                             Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
384                             LocalDateTime.now(), miioCom.getTimeDelta());
385                     deviceId = Utils.getHex(miIoResponse.getDeviceId());
386                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}", deviceId,
387                             configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
388                             miioCom.getTimeDelta());
389                     miioCom.setDeviceId(miIoResponse.getDeviceId());
390                     logger.debug("Using retrieved Mi device ID: {}", deviceId);
391                     updateDeviceIdConfig(deviceId);
392                     miioCom.registerListener(this);
393                     this.miioCom = miioCom;
394                     return miioCom;
395                 } else {
396                     miioCom.close();
397                 }
398             }
399             logger.debug("Ping response from device {} at {} FAILED", configuration.deviceId, configuration.host);
400             disconnectedNoResponse();
401             return null;
402         } catch (IOException e) {
403             logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
404             disconnected(e.getMessage());
405             return null;
406         }
407     }
408
409     private void updateDeviceIdConfig(String deviceId) {
410         if (!deviceId.isEmpty()) {
411             updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
412             Configuration config = editConfiguration();
413             config.put(PROPERTY_DID, deviceId);
414             updateConfiguration(config);
415         } else {
416             logger.debug("Could not update config with device ID: {}", deviceId);
417         }
418     }
419
420     protected boolean initializeData() {
421         this.miioCom = getConnection();
422         return true;
423     }
424
425     protected void refreshNetwork() {
426         network.getValue();
427     }
428
429     protected void defineDeviceType(JsonObject miioInfo) {
430         updateProperties(miioInfo);
431         isIdentified = updateThingType(miioInfo);
432     }
433
434     private void updateProperties(JsonObject miioInfo) {
435         final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
436         if (info == null) {
437             return;
438         }
439         Map<String, String> properties = editProperties();
440         if (info.model != null) {
441             properties.put(Thing.PROPERTY_MODEL_ID, info.model);
442         }
443         if (info.fwVer != null) {
444             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
445         }
446         if (info.hwVer != null) {
447             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
448         }
449         if (info.wifiFwVer != null) {
450             properties.put("wifiFirmware", info.wifiFwVer);
451         }
452         if (info.mcuFwVer != null) {
453             properties.put("mcuFirmware", info.mcuFwVer);
454         }
455         deviceVariables.putAll(properties);
456         updateProperties(properties);
457     }
458
459     protected boolean updateThingType(JsonObject miioInfo) {
460         MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
461         String model = miioInfo.get("model").getAsString();
462         miDevice = MiIoDevices.getType(model);
463         if (configuration.model.isEmpty()) {
464             Configuration config = editConfiguration();
465             config.put(PROPERTY_MODEL, model);
466             updateConfiguration(config);
467             configuration = getConfigAs(MiIoBindingConfiguration.class);
468         }
469         if (!configuration.model.equals(model)) {
470             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
471                     configuration.model);
472         }
473         if (miDevice.getThingType().equals(getThing().getThingTypeUID())
474                 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
475                         && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
476             logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
477                     miDevice.getThingType().toString());
478             return true;
479         } else {
480             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
481                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
482                 changeType(model);
483             } else {
484                 logger.info(
485                         "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
486                         miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
487                         miDevice.getThingType().toString());
488                 return true;
489             }
490         }
491         return false;
492     }
493
494     /**
495      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
496      * the device.
497      *
498      * @param modelId String with the model id
499      */
500     private void changeType(final String modelId) {
501         final ScheduledFuture<?> pollingJob = this.pollingJob;
502         if (pollingJob != null) {
503             pollingJob.cancel(true);
504             this.pollingJob = null;
505         }
506         miIoScheduler.schedule(() -> {
507             String label = getThing().getLabel();
508             if (label == null || label.startsWith("Xiaomi Mi Device")) {
509                 ThingBuilder thingBuilder = editThing();
510                 thingBuilder.withLabel(miDevice.getDescription());
511                 updateThing(thingBuilder.build());
512             }
513             logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
514                     modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
515                     miDevice.getThingType().toString());
516             ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
517             if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
518                     && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
519                 thingTypeUID = THING_TYPE_BASIC;
520             }
521             changeThingType(thingTypeUID, getConfig());
522         }, 10, TimeUnit.SECONDS);
523     }
524
525     @Override
526     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
527         updateStatus(status, statusDetail);
528     }
529
530     @Override
531     public void onMessageReceived(MiIoSendCommand response) {
532         logger.debug("Received response for {} type: {}, result: {}, fullresponse: {}", getThing().getUID().getId(),
533                 response.getCommand(), response.getResult(), response.getResponse());
534         if (response.isError()) {
535             logger.debug("Error received: {}", response.getResponse().get("error"));
536             if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
537                 network.invalidateValue();
538             }
539             return;
540         }
541         try {
542             switch (response.getCommand()) {
543                 case MIIO_INFO:
544                     if (!isIdentified) {
545                         defineDeviceType(response.getResult().getAsJsonObject());
546                     }
547                     updateNetwork(response.getResult().getAsJsonObject());
548                     break;
549                 default:
550                     break;
551             }
552             if (cmds.containsKey(response.getId())) {
553                 if (response.getCloudServer().isBlank()) {
554                     updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
555                 } else {
556                     updateState(CHANNEL_RPC, new StringType(response.getResponse().toString()));
557                 }
558                 cmds.remove(response.getId());
559             }
560         } catch (Exception e) {
561             logger.debug("Error while handing message {}", response.getResponse(), e);
562         }
563     }
564 }