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