]> git.basschouten.com Git - openhab-addons.git/blob
0edcd422cfb4d5d2f1bd76603dccaf66cf0b8a5d
[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(String message) {
282         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, message);
283         final MiIoAsyncCommunication miioCom = this.miioCom;
284         if (miioCom != null) {
285             lastId = miioCom.getId();
286             lastId += 10;
287         }
288     }
289
290     protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
291         if (miioCom != null) {
292             return miioCom;
293         }
294         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
295         if (configuration.host == null || configuration.host.isEmpty()) {
296             return null;
297         }
298         @Nullable
299         String deviceId = configuration.deviceId;
300         try {
301             if (deviceId != null && deviceId.length() == 8 && tokenCheckPass(configuration.token)) {
302                 logger.debug("Ping Mi device {} at {}", deviceId, configuration.host);
303                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
304                         Utils.hexStringToByteArray(deviceId), lastId, configuration.timeout);
305                 Message miIoResponse = miioCom.sendPing(configuration.host);
306                 if (miIoResponse != null) {
307                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
308                             Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
309                             LocalDateTime.now(), miioCom.getTimeDelta());
310                     miioCom.registerListener(this);
311                     this.miioCom = miioCom;
312                     return miioCom;
313                 } else {
314                     miioCom.close();
315                 }
316             } else {
317                 logger.debug("No device ID defined. Retrieving Mi device ID");
318                 final MiIoAsyncCommunication miioCom = new MiIoAsyncCommunication(configuration.host, token,
319                         new byte[0], lastId, configuration.timeout);
320                 Message miIoResponse = miioCom.sendPing(configuration.host);
321                 if (miIoResponse != null) {
322                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}",
323                             Utils.getHex(miIoResponse.getDeviceId()), configuration.host, miIoResponse.getTimestamp(),
324                             LocalDateTime.now(), miioCom.getTimeDelta());
325                     deviceId = Utils.getHex(miIoResponse.getDeviceId());
326                     logger.debug("Ping response from device {} at {}. Time stamp: {}, OH time {}, delta {}", deviceId,
327                             configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
328                             miioCom.getTimeDelta());
329                     miioCom.setDeviceId(miIoResponse.getDeviceId());
330                     logger.debug("Using retrieved Mi device ID: {}", deviceId);
331                     updateDeviceIdConfig(deviceId);
332                     miioCom.registerListener(this);
333                     this.miioCom = miioCom;
334                     return miioCom;
335                 } else {
336                     miioCom.close();
337                 }
338             }
339             logger.debug("Ping response from device {} at {} FAILED", configuration.deviceId, configuration.host);
340             disconnectedNoResponse();
341             return null;
342         } catch (IOException e) {
343             logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
344             disconnected(e.getMessage());
345             return null;
346         }
347     }
348
349     private void updateDeviceIdConfig(String deviceId) {
350         if (!deviceId.isEmpty()) {
351             updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
352             Configuration config = editConfiguration();
353             config.put(PROPERTY_DID, deviceId);
354             updateConfiguration(config);
355         } else {
356             logger.debug("Could not update config with device ID: {}", deviceId);
357         }
358     }
359
360     protected boolean initializeData() {
361         this.miioCom = getConnection();
362         return true;
363     }
364
365     protected void refreshNetwork() {
366         network.getValue();
367     }
368
369     protected void defineDeviceType(JsonObject miioInfo) {
370         updateProperties(miioInfo);
371         isIdentified = updateThingType(miioInfo);
372     }
373
374     private void updateProperties(JsonObject miioInfo) {
375         final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
376         Map<String, String> properties = editProperties();
377         if (info.model != null) {
378             properties.put(Thing.PROPERTY_MODEL_ID, info.model);
379         }
380         if (info.fwVer != null) {
381             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
382         }
383         if (info.hwVer != null) {
384             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
385         }
386         if (info.wifiFwVer != null) {
387             properties.put("wifiFirmware", info.wifiFwVer);
388         }
389         if (info.mcuFwVer != null) {
390             properties.put("mcuFirmware", info.mcuFwVer);
391         }
392         deviceVariables.putAll(properties);
393         updateProperties(properties);
394     }
395
396     protected boolean updateThingType(JsonObject miioInfo) {
397         MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
398         String model = miioInfo.get("model").getAsString();
399         miDevice = MiIoDevices.getType(model);
400         if (configuration.model == null || configuration.model.isEmpty()) {
401             Configuration config = editConfiguration();
402             config.put(PROPERTY_MODEL, model);
403             updateConfiguration(config);
404             configuration = getConfigAs(MiIoBindingConfiguration.class);
405         }
406         if (!configuration.model.equals(model)) {
407             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
408                     configuration.model);
409         }
410         if (miDevice.getThingType().equals(getThing().getThingTypeUID())
411                 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
412                         && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
413             logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
414                     miDevice.getThingType().toString());
415             return true;
416         } else {
417             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
418                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
419                 changeType(model);
420             } else {
421                 logger.info(
422                         "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
423                         miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
424                         miDevice.getThingType().toString());
425                 return true;
426             }
427         }
428         return false;
429     }
430
431     /**
432      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
433      * the device.
434      *
435      * @param modelId String with the model id
436      */
437     private void changeType(final String modelId) {
438         final ScheduledFuture<?> pollingJob = this.pollingJob;
439         if (pollingJob != null) {
440             pollingJob.cancel(true);
441             this.pollingJob = null;
442         }
443         scheduler.schedule(() -> {
444             ThingBuilder thingBuilder = editThing();
445             thingBuilder.withLabel(miDevice.getDescription());
446             updateThing(thingBuilder.build());
447             logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
448                     modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
449                     miDevice.getThingType().toString());
450             ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
451             if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
452                     && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
453                 thingTypeUID = THING_TYPE_BASIC;
454             }
455             changeThingType(thingTypeUID, getConfig());
456         }, 10, TimeUnit.SECONDS);
457     }
458
459     @Override
460     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
461         updateStatus(status, statusDetail);
462     }
463
464     @Override
465     public void onMessageReceived(MiIoSendCommand response) {
466         logger.debug("Received response for {} type: {}, result: {}, fullresponse: {}", getThing().getUID().getId(),
467                 response.getCommand(), response.getResult(), response.getResponse());
468         if (response.isError()) {
469             logger.debug("Error received: {}", response.getResponse().get("error"));
470             if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
471                 network.invalidateValue();
472             }
473             return;
474         }
475         try {
476             switch (response.getCommand()) {
477                 case MIIO_INFO:
478                     if (!isIdentified) {
479                         defineDeviceType(response.getResult().getAsJsonObject());
480                     }
481                     updateNetwork(response.getResult().getAsJsonObject());
482                     break;
483                 default:
484                     break;
485             }
486             if (cmds.containsKey(response.getId())) {
487                 updateState(CHANNEL_COMMAND, new StringType(response.getResponse().toString()));
488                 cmds.remove(response.getId());
489             }
490         } catch (Exception e) {
491             logger.debug("Error while handing message {}", response.getResponse(), e);
492         }
493     }
494 }