]> git.basschouten.com Git - openhab-addons.git/blob
bdd6106fbd278b101295998b032d9499e5c5544b
[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 import com.google.gson.JsonParser;
62
63 /**
64  * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
65  * sent to one of the channels.
66  *
67  * @author Marcel Verpaalen - Initial contribution
68  */
69 @NonNullByDefault
70 public abstract class MiIoAbstractHandler extends BaseThingHandler implements MiIoMessageListener {
71     protected static final int MAX_QUEUE = 5;
72     protected static final Gson GSON = new GsonBuilder().create();
73
74     protected ScheduledExecutorService miIoScheduler = scheduler;
75     protected @Nullable ScheduledFuture<?> pollingJob;
76     protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
77     protected boolean isIdentified;
78
79     protected final JsonParser parser = new JsonParser();
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 == null || 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         cloudServer = (configuration.cloudServer != null) ? 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 && configuration.communication != 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             updateState(CHANNEL_SSID, new StringType(networkData.getAsJsonObject("ap").get("ssid").getAsString()));
298             updateState(CHANNEL_BSSID, new StringType(networkData.getAsJsonObject("ap").get("bssid").getAsString()));
299             if (networkData.getAsJsonObject("ap").get("rssi") != null) {
300                 updateState(CHANNEL_RSSI, new DecimalType(networkData.getAsJsonObject("ap").get("rssi").getAsLong()));
301             } else if (networkData.getAsJsonObject("ap").get("wifi_rssi") != null) {
302                 updateState(CHANNEL_RSSI,
303                         new DecimalType(networkData.getAsJsonObject("ap").get("wifi_rssi").getAsLong()));
304             } else {
305                 logger.debug("No RSSI info in response");
306             }
307             updateState(CHANNEL_LIFE, new DecimalType(networkData.get("life").getAsLong()));
308             return true;
309         } catch (Exception e) {
310             logger.debug("Could not parse network response: {}", networkData, e);
311         }
312         return false;
313     }
314
315     protected boolean hasConnection() {
316         return getConnection() != null;
317     }
318
319     protected void disconnectedNoResponse() {
320         disconnected("No Response from device");
321     }
322
323     protected void disconnected(@Nullable String message) {
324         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
325                 message != null ? message : "");
326         final MiIoAsyncCommunication miioCom = this.miioCom;
327         if (miioCom != null) {
328             lastId = miioCom.getId();
329             lastId += 10;
330         }
331     }
332
333     protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
334         if (miioCom != null) {
335             return miioCom;
336         }
337         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
338         if (configuration.host == null || configuration.host.isEmpty()) {
339             return null;
340         }
341         @Nullable
342         String deviceId = configuration.deviceId;
343         try {
344             if (deviceId != null && deviceId.length() == 8 && tokenCheckPass(configuration.token)) {
345                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
346                         Utils.hexStringToByteArray(deviceId), lastId, configuration.timeout, cloudConnector);
347                 if (getCloudServer().isBlank()) {
348                     logger.debug("Ping Mi device {} at {}", deviceId, configuration.host);
349                     Message miIoResponse = miioCom.sendPing(configuration.host);
350                     if (miIoResponse != null) {
351                         logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
352                                 Utils.getHex(miIoResponse.getDeviceId()), configuration.host,
353                                 miIoResponse.getTimestamp(), LocalDateTime.now(), miioCom.getTimeDelta());
354                         miioCom.registerListener(this);
355                         this.miioCom = miioCom;
356                         return miioCom;
357                     } else {
358                         miioCom.close();
359                     }
360                 } else {
361                     miioCom.registerListener(this);
362                     this.miioCom = miioCom;
363                     return miioCom;
364                 }
365             } else {
366                 logger.debug("No device ID defined. Retrieving Mi device ID");
367                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
368                         new byte[0], lastId, configuration.timeout, cloudConnector);
369                 Message miIoResponse = miioCom.sendPing(configuration.host);
370                 if (miIoResponse != null) {
371                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
372                             Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
373                             LocalDateTime.now(), miioCom.getTimeDelta());
374                     deviceId = Utils.getHex(miIoResponse.getDeviceId());
375                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}", deviceId,
376                             configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
377                             miioCom.getTimeDelta());
378                     miioCom.setDeviceId(miIoResponse.getDeviceId());
379                     logger.debug("Using retrieved Mi device ID: {}", deviceId);
380                     updateDeviceIdConfig(deviceId);
381                     miioCom.registerListener(this);
382                     this.miioCom = miioCom;
383                     return miioCom;
384                 } else {
385                     miioCom.close();
386                 }
387             }
388             logger.debug("Ping response from device {} at {} FAILED", configuration.deviceId, configuration.host);
389             disconnectedNoResponse();
390             return null;
391         } catch (IOException e) {
392             logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
393             disconnected(e.getMessage());
394             return null;
395         }
396     }
397
398     private void updateDeviceIdConfig(String deviceId) {
399         if (!deviceId.isEmpty()) {
400             updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
401             Configuration config = editConfiguration();
402             config.put(PROPERTY_DID, deviceId);
403             updateConfiguration(config);
404         } else {
405             logger.debug("Could not update config with device ID: {}", deviceId);
406         }
407     }
408
409     protected boolean initializeData() {
410         this.miioCom = getConnection();
411         return true;
412     }
413
414     protected void refreshNetwork() {
415         network.getValue();
416     }
417
418     protected void defineDeviceType(JsonObject miioInfo) {
419         updateProperties(miioInfo);
420         isIdentified = updateThingType(miioInfo);
421     }
422
423     private void updateProperties(JsonObject miioInfo) {
424         final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
425         Map<String, String> properties = editProperties();
426         if (info.model != null) {
427             properties.put(Thing.PROPERTY_MODEL_ID, info.model);
428         }
429         if (info.fwVer != null) {
430             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
431         }
432         if (info.hwVer != null) {
433             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
434         }
435         if (info.wifiFwVer != null) {
436             properties.put("wifiFirmware", info.wifiFwVer);
437         }
438         if (info.mcuFwVer != null) {
439             properties.put("mcuFirmware", info.mcuFwVer);
440         }
441         deviceVariables.putAll(properties);
442         updateProperties(properties);
443     }
444
445     protected boolean updateThingType(JsonObject miioInfo) {
446         MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
447         String model = miioInfo.get("model").getAsString();
448         miDevice = MiIoDevices.getType(model);
449         if (configuration.model == null || configuration.model.isEmpty()) {
450             Configuration config = editConfiguration();
451             config.put(PROPERTY_MODEL, model);
452             updateConfiguration(config);
453             configuration = getConfigAs(MiIoBindingConfiguration.class);
454         }
455         if (!configuration.model.equals(model)) {
456             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
457                     configuration.model);
458         }
459         if (miDevice.getThingType().equals(getThing().getThingTypeUID())
460                 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
461                         && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
462             logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
463                     miDevice.getThingType().toString());
464             return true;
465         } else {
466             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
467                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
468                 changeType(model);
469             } else {
470                 logger.info(
471                         "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
472                         miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
473                         miDevice.getThingType().toString());
474                 return true;
475             }
476         }
477         return false;
478     }
479
480     /**
481      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
482      * the device.
483      *
484      * @param modelId String with the model id
485      */
486     private void changeType(final String modelId) {
487         final ScheduledFuture<?> pollingJob = this.pollingJob;
488         if (pollingJob != null) {
489             pollingJob.cancel(true);
490             this.pollingJob = null;
491         }
492         miIoScheduler.schedule(() -> {
493             String label = getThing().getLabel();
494             if (label == null || label.startsWith("Xiaomi Mi Device")) {
495                 ThingBuilder thingBuilder = editThing();
496                 thingBuilder.withLabel(miDevice.getDescription());
497                 updateThing(thingBuilder.build());
498             }
499             logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
500                     modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
501                     miDevice.getThingType().toString());
502             ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
503             if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
504                     && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
505                 thingTypeUID = THING_TYPE_BASIC;
506             }
507             changeThingType(thingTypeUID, getConfig());
508         }, 10, TimeUnit.SECONDS);
509     }
510
511     @Override
512     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
513         updateStatus(status, statusDetail);
514     }
515
516     @Override
517     public void onMessageReceived(MiIoSendCommand response) {
518         logger.debug("Received response for {} type: {}, result: {}, fullresponse: {}", getThing().getUID().getId(),
519                 response.getCommand(), response.getResult(), response.getResponse());
520         if (response.isError()) {
521             logger.debug("Error received: {}", response.getResponse().get("error"));
522             if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
523                 network.invalidateValue();
524             }
525             return;
526         }
527         try {
528             switch (response.getCommand()) {
529                 case MIIO_INFO:
530                     if (!isIdentified) {
531                         defineDeviceType(response.getResult().getAsJsonObject());
532                     }
533                     updateNetwork(response.getResult().getAsJsonObject());
534                     break;
535                 default:
536                     break;
537             }
538             if (cmds.containsKey(response.getId())) {
539                 if (response.getCloudServer().isBlank()) {
540                     updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
541                 } else {
542                     updateState(CHANNEL_RPC, new StringType(response.getResponse().toString()));
543                 }
544                 cmds.remove(response.getId());
545             }
546         } catch (Exception e) {
547             logger.debug("Error while handing message {}", response.getResponse(), e);
548         }
549     }
550 }