]> git.basschouten.com Git - openhab-addons.git/blob
b14d6ddaa1e4480463f8d554238fb0fb2cb8d9f7
[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         if (deviceId.length() == 8 && deviceId.matches("^.*[a-zA-Z]+.*$")) {
355             logger.warn(
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");
357             deviceId = "";
358         }
359         try {
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;
372                         return miioCom;
373                     } else {
374                         miioCom.close();
375                     }
376                 } else {
377                     miioCom.registerListener(this);
378                     this.miioCom = miioCom;
379                     return miioCom;
380                 }
381             } else {
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;
396                     return miioCom;
397                 } else {
398                     miioCom.close();
399                 }
400             }
401             logger.debug("Ping response from deviceId '{}' at {} FAILED", configuration.deviceId, configuration.host);
402             disconnectedNoResponse();
403             return null;
404         } catch (IOException e) {
405             logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
406             disconnected(e.getMessage());
407             return null;
408         }
409     }
410
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);
417         } else {
418             logger.debug("Could not update config with deviceId: {}", deviceId);
419         }
420     }
421
422     protected boolean initializeData() {
423         this.miioCom = getConnection();
424         return true;
425     }
426
427     protected void refreshNetwork() {
428         network.getValue();
429     }
430
431     protected void defineDeviceType(JsonObject miioInfo) {
432         updateProperties(miioInfo);
433         isIdentified = updateThingType(miioInfo);
434     }
435
436     private void updateProperties(JsonObject miioInfo) {
437         final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
438         if (info == null) {
439             return;
440         }
441         Map<String, String> properties = editProperties();
442         if (info.model != null) {
443             properties.put(Thing.PROPERTY_MODEL_ID, info.model);
444         }
445         if (info.fwVer != null) {
446             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
447         }
448         if (info.hwVer != null) {
449             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
450         }
451         if (info.wifiFwVer != null) {
452             properties.put("wifiFirmware", info.wifiFwVer);
453         }
454         if (info.mcuFwVer != null) {
455             properties.put("mcuFirmware", info.mcuFwVer);
456         }
457         deviceVariables.putAll(properties);
458         updateProperties(properties);
459     }
460
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);
470         }
471         if (!configuration.model.equals(model)) {
472             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
473                     configuration.model);
474         }
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());
480             return true;
481         } else {
482             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
483                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
484                 changeType(model);
485             } else {
486                 logger.info(
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());
490                 return true;
491             }
492         }
493         return false;
494     }
495
496     /**
497      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
498      * the device.
499      *
500      * @param modelId String with the model id
501      */
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;
507         }
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());
514             }
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;
522             }
523             changeThingType(thingTypeUID, getConfig());
524         }, 10, TimeUnit.SECONDS);
525     }
526
527     @Override
528     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
529         updateStatus(status, statusDetail);
530     }
531
532     @Override
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();
541             }
542             return;
543         }
544         try {
545             switch (response.getCommand()) {
546                 case MIIO_INFO:
547                     if (!isIdentified) {
548                         defineDeviceType(response.getResult().getAsJsonObject());
549                     }
550                     updateNetwork(response.getResult().getAsJsonObject());
551                     break;
552                 default:
553                     break;
554             }
555             if (cmds.containsKey(response.getId())) {
556                 if (response.getCloudServer().isBlank()) {
557                     updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
558                 } else {
559                     updateState(CHANNEL_RPC, new StringType(response.getResponse().toString()));
560                 }
561                 cmds.remove(response.getId());
562             }
563         } catch (Exception e) {
564             logger.debug("Error while handing message {}", response.getResponse(), e);
565         }
566     }
567 }