]> git.basschouten.com Git - openhab-addons.git/blob
891e43a23ae705699741068bd6fa6ed1c4fc31d1
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.openhab.binding.miio.internal.Message;
28 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
29 import org.openhab.binding.miio.internal.MiIoCommand;
30 import org.openhab.binding.miio.internal.MiIoCrypto;
31 import org.openhab.binding.miio.internal.MiIoCryptoException;
32 import org.openhab.binding.miio.internal.MiIoDevices;
33 import org.openhab.binding.miio.internal.MiIoInfoDTO;
34 import org.openhab.binding.miio.internal.MiIoMessageListener;
35 import org.openhab.binding.miio.internal.MiIoSendCommand;
36 import org.openhab.binding.miio.internal.Utils;
37 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
38 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
39 import org.openhab.core.cache.ExpiringCache;
40 import org.openhab.core.config.core.Configuration;
41 import org.openhab.core.library.types.DecimalType;
42 import org.openhab.core.library.types.StringType;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.ThingTypeUID;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.thing.binding.builder.ThingBuilder;
50 import org.openhab.core.types.Command;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 import com.google.gson.Gson;
55 import com.google.gson.GsonBuilder;
56 import com.google.gson.JsonObject;
57 import com.google.gson.JsonParser;
58
59 /**
60  * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
61  * sent to one of the channels.
62  *
63  * @author Marcel Verpaalen - Initial contribution
64  */
65 @NonNullByDefault
66 public abstract class MiIoAbstractHandler extends BaseThingHandler implements MiIoMessageListener {
67     protected static final int MAX_QUEUE = 5;
68     protected static final Gson GSON = new GsonBuilder().create();
69
70     protected @Nullable ScheduledFuture<?> pollingJob;
71     protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
72     protected boolean isIdentified;
73
74     protected final JsonParser parser = new JsonParser();
75     protected byte[] token = new byte[0];
76
77     protected @Nullable MiIoBindingConfiguration configuration;
78     protected @Nullable MiIoAsyncCommunication miioCom;
79     protected int lastId;
80
81     protected Map<Integer, String> cmds = new ConcurrentHashMap<>();
82     protected Map<String, Object> deviceVariables = new HashMap<>();
83     protected final ExpiringCache<String> network = new ExpiringCache<>(CACHE_EXPIRY_NETWORK, () -> {
84         int ret = sendCommand(MiIoCommand.MIIO_INFO);
85         if (ret != 0) {
86             return "id:" + ret;
87         }
88         return "failed";
89     });;
90     protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
91     protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
92
93     private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
94     protected MiIoDatabaseWatchService miIoDatabaseWatchService;
95
96     public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService) {
97         super(thing);
98         this.miIoDatabaseWatchService = miIoDatabaseWatchService;
99     }
100
101     @Override
102     public abstract void handleCommand(ChannelUID channelUID, Command command);
103
104     @Override
105     public void initialize() {
106         logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
107                 getThing().getThingTypeUID());
108         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
109         this.configuration = configuration;
110         if (configuration.host == null || configuration.host.isEmpty()) {
111             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
112                     "IP address required. Configure IP address");
113             return;
114         }
115         if (!tokenCheckPass(configuration.token)) {
116             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Token required. Configure token");
117             return;
118         }
119         isIdentified = false;
120         scheduler.schedule(this::initializeData, 1, TimeUnit.SECONDS);
121         int pollingPeriod = configuration.refreshInterval;
122         if (pollingPeriod > 0) {
123             pollingJob = scheduler.scheduleWithFixedDelay(() -> {
124                 try {
125                     updateData();
126                 } catch (Exception e) {
127                     logger.debug("Unexpected error during refresh.", e);
128                 }
129             }, 10, pollingPeriod, TimeUnit.SECONDS);
130             logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
131         } else {
132             logger.debug("Polling job disabled. for '{}'", getThing().getUID());
133             scheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
134         }
135         updateStatus(ThingStatus.OFFLINE);
136     }
137
138     private boolean tokenCheckPass(@Nullable String tokenSting) {
139         if (tokenSting == null) {
140             return false;
141         }
142         switch (tokenSting.length()) {
143             case 16:
144                 token = tokenSting.getBytes();
145                 return true;
146             case 32:
147                 if (!IGNORED_TOKENS.contains(tokenSting)) {
148                     token = Utils.hexStringToByteArray(tokenSting);
149                     return true;
150                 }
151                 return false;
152             case 96:
153                 try {
154                     token = Utils.hexStringToByteArray(MiIoCrypto.decryptToken(Utils.hexStringToByteArray(tokenSting)));
155                     logger.debug("IOS token decrypted to {}", Utils.getHex(token));
156                 } catch (MiIoCryptoException e) {
157                     logger.warn("Could not decrypt token {}{}", tokenSting, e.getMessage());
158                     return false;
159                 }
160                 return true;
161             default:
162                 return false;
163         }
164     }
165
166     @Override
167     public void dispose() {
168         logger.debug("Disposing Xiaomi Mi IO handler '{}'", getThing().getUID());
169         final ScheduledFuture<?> pollingJob = this.pollingJob;
170         if (pollingJob != null) {
171             pollingJob.cancel(true);
172             this.pollingJob = null;
173         }
174         final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
175         if (miioCom != null) {
176             lastId = miioCom.getId();
177             miioCom.unregisterListener(this);
178             miioCom.close();
179             this.miioCom = null;
180         }
181     }
182
183     protected int sendCommand(MiIoCommand command) {
184         return sendCommand(command, "[]");
185     }
186
187     protected int sendCommand(MiIoCommand command, String params) {
188         try {
189             final MiIoAsyncCommunication connection = getConnection();
190             return (connection != null) ? connection.queueCommand(command, params) : 0;
191         } catch (MiIoCryptoException | IOException e) {
192             logger.debug("Command {} for {} failed (type: {}): {}", command.toString(), getThing().getUID(),
193                     getThing().getThingTypeUID(), e.getLocalizedMessage());
194         }
195         return 0;
196     }
197
198     /**
199      * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
200      * between
201      * [] brackets. This to allow for unimplemented commands to be executed (e.g. get detailed historical cleaning
202      * records)
203      *
204      * @param commandString command to be executed
205      * @return vacuum response
206      */
207     protected int sendCommand(String commandString) {
208         final MiIoAsyncCommunication connection = getConnection();
209         try {
210             String command = commandString.trim();
211             String param = "[]";
212             int sb = command.indexOf("[");
213             int cb = command.indexOf("{");
214             logger.debug("locs {}, {}", sb, cb);
215             if (Math.max(sb, cb) > 0) {
216                 int loc = (Math.min(sb, cb) > 0 ? Math.min(sb, cb) : Math.max(sb, cb));
217                 param = command.substring(loc).trim();
218                 command = command.substring(0, loc).trim();
219             }
220             return (connection != null) ? connection.queueCommand(command, param) : 0;
221         } catch (MiIoCryptoException | IOException e) {
222             disconnected(e.getMessage());
223         }
224         return 0;
225     }
226
227     protected boolean skipUpdate() {
228         final MiIoAsyncCommunication miioCom = this.miioCom;
229         if (!hasConnection() || miioCom == null) {
230             logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
231             return true;
232         }
233         if (getThing().getStatusInfo().getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)) {
234             logger.debug("Skipping periodic update for '{}'. Thing Status {}", getThing().getUID().toString(),
235                     getThing().getStatusInfo().getStatusDetail());
236             try {
237                 miioCom.queueCommand(MiIoCommand.MIIO_INFO);
238             } catch (MiIoCryptoException | IOException e) {
239                 // ignore
240             }
241             return true;
242         }
243         if (miioCom.getQueueLength() > MAX_QUEUE) {
244             logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
245                     miioCom.getQueueLength());
246             return true;
247         }
248         return false;
249     }
250
251     protected abstract void updateData();
252
253     protected boolean updateNetwork(JsonObject networkData) {
254         try {
255             updateState(CHANNEL_SSID, new StringType(networkData.getAsJsonObject("ap").get("ssid").getAsString()));
256             updateState(CHANNEL_BSSID, new StringType(networkData.getAsJsonObject("ap").get("bssid").getAsString()));
257             if (networkData.getAsJsonObject("ap").get("rssi") != null) {
258                 updateState(CHANNEL_RSSI, new DecimalType(networkData.getAsJsonObject("ap").get("rssi").getAsLong()));
259             } else if (networkData.getAsJsonObject("ap").get("wifi_rssi") != null) {
260                 updateState(CHANNEL_RSSI,
261                         new DecimalType(networkData.getAsJsonObject("ap").get("wifi_rssi").getAsLong()));
262             } else {
263                 logger.debug("No RSSI info in response");
264             }
265             updateState(CHANNEL_LIFE, new DecimalType(networkData.get("life").getAsLong()));
266             return true;
267         } catch (Exception e) {
268             logger.debug("Could not parse network response: {}", networkData, e);
269         }
270         return false;
271     }
272
273     protected boolean hasConnection() {
274         return getConnection() != null;
275     }
276
277     protected void disconnectedNoResponse() {
278         disconnected("No Response from device");
279     }
280
281     protected void disconnected(@Nullable String message) {
282         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
283                 message != null ? message : "");
284         final MiIoAsyncCommunication miioCom = this.miioCom;
285         if (miioCom != null) {
286             lastId = miioCom.getId();
287             lastId += 10;
288         }
289     }
290
291     protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
292         if (miioCom != null) {
293             return miioCom;
294         }
295         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
296         if (configuration.host == null || configuration.host.isEmpty()) {
297             return null;
298         }
299         @Nullable
300         String deviceId = configuration.deviceId;
301         try {
302             if (deviceId != null && deviceId.length() == 8 && tokenCheckPass(configuration.token)) {
303                 logger.debug("Ping Mi device {} at {}", deviceId, configuration.host);
304                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
305                         Utils.hexStringToByteArray(deviceId), lastId, configuration.timeout);
306                 Message miIoResponse = miioCom.sendPing(configuration.host);
307                 if (miIoResponse != null) {
308                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
309                             Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
310                             LocalDateTime.now(), miioCom.getTimeDelta());
311                     miioCom.registerListener(this);
312                     this.miioCom = miioCom;
313                     return miioCom;
314                 } else {
315                     miioCom.close();
316                 }
317             } else {
318                 logger.debug("No device ID defined. Retrieving Mi device ID");
319                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
320                         new byte[0], lastId, configuration.timeout);
321                 Message miIoResponse = miioCom.sendPing(configuration.host);
322                 if (miIoResponse != null) {
323                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
324                             Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
325                             LocalDateTime.now(), miioCom.getTimeDelta());
326                     deviceId = Utils.getHex(miIoResponse.getDeviceId());
327                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}", deviceId,
328                             configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
329                             miioCom.getTimeDelta());
330                     miioCom.setDeviceId(miIoResponse.getDeviceId());
331                     logger.debug("Using retrieved Mi device ID: {}", deviceId);
332                     updateDeviceIdConfig(deviceId);
333                     miioCom.registerListener(this);
334                     this.miioCom = miioCom;
335                     return miioCom;
336                 } else {
337                     miioCom.close();
338                 }
339             }
340             logger.debug("Ping response from device {} at {} FAILED", configuration.deviceId, configuration.host);
341             disconnectedNoResponse();
342             return null;
343         } catch (IOException e) {
344             logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
345             disconnected(e.getMessage());
346             return null;
347         }
348     }
349
350     private void updateDeviceIdConfig(String deviceId) {
351         if (!deviceId.isEmpty()) {
352             updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
353             Configuration config = editConfiguration();
354             config.put(PROPERTY_DID, deviceId);
355             updateConfiguration(config);
356         } else {
357             logger.debug("Could not update config with device ID: {}", deviceId);
358         }
359     }
360
361     protected boolean initializeData() {
362         this.miioCom = getConnection();
363         return true;
364     }
365
366     protected void refreshNetwork() {
367         network.getValue();
368     }
369
370     protected void defineDeviceType(JsonObject miioInfo) {
371         updateProperties(miioInfo);
372         isIdentified = updateThingType(miioInfo);
373     }
374
375     private void updateProperties(JsonObject miioInfo) {
376         final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
377         Map<String, String> properties = editProperties();
378         if (info.model != null) {
379             properties.put(Thing.PROPERTY_MODEL_ID, info.model);
380         }
381         if (info.fwVer != null) {
382             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
383         }
384         if (info.hwVer != null) {
385             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
386         }
387         if (info.wifiFwVer != null) {
388             properties.put("wifiFirmware", info.wifiFwVer);
389         }
390         if (info.mcuFwVer != null) {
391             properties.put("mcuFirmware", info.mcuFwVer);
392         }
393         deviceVariables.putAll(properties);
394         updateProperties(properties);
395     }
396
397     protected boolean updateThingType(JsonObject miioInfo) {
398         MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
399         String model = miioInfo.get("model").getAsString();
400         miDevice = MiIoDevices.getType(model);
401         if (configuration.model == null || configuration.model.isEmpty()) {
402             Configuration config = editConfiguration();
403             config.put(PROPERTY_MODEL, model);
404             updateConfiguration(config);
405             configuration = getConfigAs(MiIoBindingConfiguration.class);
406         }
407         if (!configuration.model.equals(model)) {
408             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
409                     configuration.model);
410         }
411         if (miDevice.getThingType().equals(getThing().getThingTypeUID())
412                 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
413                         && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
414             logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
415                     miDevice.getThingType().toString());
416             return true;
417         } else {
418             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
419                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
420                 changeType(model);
421             } else {
422                 logger.info(
423                         "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
424                         miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
425                         miDevice.getThingType().toString());
426                 return true;
427             }
428         }
429         return false;
430     }
431
432     /**
433      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
434      * the device.
435      *
436      * @param modelId String with the model id
437      */
438     private void changeType(final String modelId) {
439         final ScheduledFuture<?> pollingJob = this.pollingJob;
440         if (pollingJob != null) {
441             pollingJob.cancel(true);
442             this.pollingJob = null;
443         }
444         scheduler.schedule(() -> {
445             ThingBuilder thingBuilder = editThing();
446             thingBuilder.withLabel(miDevice.getDescription());
447             updateThing(thingBuilder.build());
448             logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
449                     modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
450                     miDevice.getThingType().toString());
451             ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
452             if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
453                     && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
454                 thingTypeUID = THING_TYPE_BASIC;
455             }
456             changeThingType(thingTypeUID, getConfig());
457         }, 10, TimeUnit.SECONDS);
458     }
459
460     @Override
461     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
462         updateStatus(status, statusDetail);
463     }
464
465     @Override
466     public void onMessageReceived(MiIoSendCommand response) {
467         logger.debug("Received response for {} type: {}, result: {}, fullresponse: {}", getThing().getUID().getId(),
468                 response.getCommand(), response.getResult(), response.getResponse());
469         if (response.isError()) {
470             logger.debug("Error received: {}", response.getResponse().get("error"));
471             if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
472                 network.invalidateValue();
473             }
474             return;
475         }
476         try {
477             switch (response.getCommand()) {
478                 case MIIO_INFO:
479                     if (!isIdentified) {
480                         defineDeviceType(response.getResult().getAsJsonObject());
481                     }
482                     updateNetwork(response.getResult().getAsJsonObject());
483                     break;
484                 default:
485                     break;
486             }
487             if (cmds.containsKey(response.getId())) {
488                 updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
489                 cmds.remove(response.getId());
490             }
491         } catch (Exception e) {
492             logger.debug("Error while handing message {}", response.getResponse(), e);
493         }
494     }
495 }