]> git.basschouten.com Git - openhab-addons.git/blob
8f22893d3fd9927c6ed59d8ac89b9fa54ce91edb
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.File;
18 import java.io.IOException;
19 import java.math.BigDecimal;
20 import java.net.URISyntaxException;
21 import java.net.URL;
22 import java.time.Instant;
23 import java.time.LocalDateTime;
24 import java.util.HashMap;
25 import java.util.Map;
26 import java.util.Map.Entry;
27 import java.util.concurrent.ConcurrentHashMap;
28 import java.util.concurrent.ScheduledExecutorService;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.ScheduledThreadPoolExecutor;
31 import java.util.concurrent.TimeUnit;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.miio.internal.Message;
36 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
37 import org.openhab.binding.miio.internal.MiIoCommand;
38 import org.openhab.binding.miio.internal.MiIoCrypto;
39 import org.openhab.binding.miio.internal.MiIoCryptoException;
40 import org.openhab.binding.miio.internal.MiIoDevices;
41 import org.openhab.binding.miio.internal.MiIoInfoApDTO;
42 import org.openhab.binding.miio.internal.MiIoInfoDTO;
43 import org.openhab.binding.miio.internal.MiIoMessageListener;
44 import org.openhab.binding.miio.internal.MiIoSendCommand;
45 import org.openhab.binding.miio.internal.SavedDeviceInfoDTO;
46 import org.openhab.binding.miio.internal.Utils;
47 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
48 import org.openhab.binding.miio.internal.cloud.CloudConnector;
49 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
50 import org.openhab.core.cache.ExpiringCache;
51 import org.openhab.core.common.NamedThreadFactory;
52 import org.openhab.core.config.core.Configuration;
53 import org.openhab.core.i18n.LocaleProvider;
54 import org.openhab.core.i18n.TranslationProvider;
55 import org.openhab.core.library.types.DecimalType;
56 import org.openhab.core.library.types.StringType;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.ThingTypeUID;
62 import org.openhab.core.thing.binding.BaseThingHandler;
63 import org.openhab.core.thing.binding.builder.ThingBuilder;
64 import org.openhab.core.types.Command;
65 import org.osgi.framework.Bundle;
66 import org.osgi.framework.FrameworkUtil;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
69
70 import com.google.gson.Gson;
71 import com.google.gson.GsonBuilder;
72 import com.google.gson.JsonObject;
73 import com.google.gson.JsonParseException;
74 import com.google.gson.JsonPrimitive;
75 import com.google.gson.JsonSyntaxException;
76
77 /**
78  * The {@link MiIoAbstractHandler} is responsible for handling commands, which are
79  * sent to one of the channels.
80  *
81  * @author Marcel Verpaalen - Initial contribution
82  */
83 @NonNullByDefault
84 public abstract class MiIoAbstractHandler extends BaseThingHandler implements MiIoMessageListener {
85     protected static final int MAX_QUEUE = 5;
86     protected static final Gson GSON = new GsonBuilder().create();
87     protected static final String TIMESTAMP = "timestamp";
88     protected final Bundle bundle;
89     protected final TranslationProvider i18nProvider;
90     protected final LocaleProvider localeProvider;
91     protected final Map<Thing, MiIoLumiHandler> childDevices = new ConcurrentHashMap<>();
92
93     protected ScheduledExecutorService miIoScheduler = new ScheduledThreadPoolExecutor(3,
94             new NamedThreadFactory("binding-" + getThing().getUID().getAsString(), true));
95
96     protected @Nullable ScheduledFuture<?> pollingJob;
97     protected MiIoDevices miDevice = MiIoDevices.UNKNOWN;
98     protected boolean isIdentified;
99
100     protected byte[] token = new byte[0];
101
102     protected @Nullable MiIoBindingConfiguration configuration;
103     protected @Nullable MiIoAsyncCommunication miioCom;
104     protected CloudConnector cloudConnector;
105     protected String cloudServer = "";
106     protected String deviceId = "";
107     protected int lastId;
108
109     protected Map<Integer, String> cmds = new ConcurrentHashMap<>();
110     protected Map<String, Object> deviceVariables = new HashMap<>();
111     protected final ExpiringCache<String> network = new ExpiringCache<>(CACHE_EXPIRY_NETWORK, () -> {
112         int ret = sendCommand(MiIoCommand.MIIO_INFO);
113         if (ret != 0) {
114             return "id:" + ret;
115         }
116         return "failed";
117     });;
118     protected static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
119     protected static final long CACHE_EXPIRY_NETWORK = TimeUnit.SECONDS.toMillis(60);
120
121     private final Logger logger = LoggerFactory.getLogger(MiIoAbstractHandler.class);
122     protected MiIoDatabaseWatchService miIoDatabaseWatchService;
123
124     public MiIoAbstractHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
125             CloudConnector cloudConnector, TranslationProvider i18nProvider, LocaleProvider localeProvider) {
126         super(thing);
127         this.miIoDatabaseWatchService = miIoDatabaseWatchService;
128         this.cloudConnector = cloudConnector;
129         this.i18nProvider = i18nProvider;
130         this.localeProvider = localeProvider;
131         this.bundle = FrameworkUtil.getBundle(this.getClass());
132     }
133
134     @Override
135     public abstract void handleCommand(ChannelUID channelUID, Command command);
136
137     protected boolean handleCommandsChannels(ChannelUID channelUID, Command command) {
138         String cmd = processSubstitutions(command.toString(), deviceVariables);
139         if (channelUID.getId().equals(CHANNEL_COMMAND)) {
140             cmds.put(sendCommand(cmd), channelUID.getId());
141             return true;
142         }
143         if (channelUID.getId().equals(CHANNEL_RPC)) {
144             cmds.put(sendCommand(cmd, cloudServer), channelUID.getId());
145             return true;
146         }
147         return false;
148     }
149
150     @Override
151     public void initialize() {
152         logger.debug("Initializing Mi IO device handler '{}' with thingType {}", getThing().getUID(),
153                 getThing().getThingTypeUID());
154
155         ScheduledThreadPoolExecutor miIoScheduler = new ScheduledThreadPoolExecutor(3,
156                 new NamedThreadFactory("binding-" + getThing().getUID().getAsString(), true));
157         miIoScheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
158         miIoScheduler.setRemoveOnCancelPolicy(true);
159         this.miIoScheduler = miIoScheduler;
160
161         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
162         this.configuration = configuration;
163         if (!getThing().getThingTypeUID().equals(THING_TYPE_LUMI)) {
164             if (configuration.host.isEmpty()) {
165                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
166                         "@text/offline.config-error-ip");
167                 return;
168             }
169             if (!tokenCheckPass(configuration.token)) {
170                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
171                         "@text/offline.config-error-token");
172                 return;
173             }
174         }
175         this.cloudServer = configuration.cloudServer;
176         this.deviceId = configuration.deviceId;
177         isIdentified = false;
178         deviceVariables.put(TIMESTAMP, Instant.now().getEpochSecond());
179         deviceVariables.put(PROPERTY_DID, configuration.deviceId);
180         try {
181             URL url = new File(BINDING_USERDATA_PATH, "info_" + getThing().getUID().getId() + ".json").toURI().toURL();
182             SavedDeviceInfoDTO lastIdInfo = GSON.fromJson(Utils.convertFileToJSON(url), SavedDeviceInfoDTO.class);
183             if (lastIdInfo != null) {
184                 lastId = lastIdInfo.getLastId();
185                 logger.debug("Last Id set to {} for {}", lastId, getThing().getUID());
186             }
187         } catch (JsonParseException | IOException | URISyntaxException e) {
188             logger.debug("Could not read last connection id for {} : {}", getThing().getUID(), e.getMessage());
189         }
190
191         miIoScheduler.schedule(this::initializeData, 1, TimeUnit.SECONDS);
192         int pollingPeriod = configuration.refreshInterval;
193         if (pollingPeriod > 0) {
194             pollingJob = miIoScheduler.scheduleWithFixedDelay(() -> {
195                 try {
196                     updateData();
197                 } catch (Exception e) {
198                     logger.debug("Unexpected error during refresh.", e);
199                 }
200             }, 10, pollingPeriod, TimeUnit.SECONDS);
201             logger.debug("Polling job scheduled to run every {} sec. for '{}'", pollingPeriod, getThing().getUID());
202         } else {
203             logger.debug("Polling job disabled. for '{}'", getThing().getUID());
204             miIoScheduler.schedule(this::updateData, 10, TimeUnit.SECONDS);
205         }
206         updateStatus(ThingStatus.OFFLINE);
207     }
208
209     private boolean tokenCheckPass(@Nullable String tokenSting) {
210         if (tokenSting == null) {
211             return false;
212         }
213         switch (tokenSting.length()) {
214             case 16:
215                 token = tokenSting.getBytes();
216                 return true;
217             case 32:
218                 if (!IGNORED_TOKENS.contains(tokenSting)) {
219                     token = Utils.hexStringToByteArray(tokenSting);
220                     return true;
221                 }
222                 return false;
223             case 96:
224                 try {
225                     token = Utils.hexStringToByteArray(MiIoCrypto.decryptToken(Utils.hexStringToByteArray(tokenSting)));
226                     logger.debug("IOS token decrypted to {}", Utils.getHex(token));
227                 } catch (MiIoCryptoException e) {
228                     logger.warn("Could not decrypt token {}{}", tokenSting, e.getMessage());
229                     return false;
230                 }
231                 return true;
232             default:
233                 return false;
234         }
235     }
236
237     @Override
238     public void dispose() {
239         logger.debug("Disposing Xiaomi Mi IO handler '{}'", getThing().getUID());
240         miIoScheduler.shutdown();
241         final ScheduledFuture<?> pollingJob = this.pollingJob;
242         if (pollingJob != null) {
243             pollingJob.cancel(true);
244             this.pollingJob = null;
245         }
246         final @Nullable MiIoAsyncCommunication miioCom = this.miioCom;
247         if (miioCom != null) {
248             lastId = miioCom.getId();
249             miioCom.unregisterListener(this);
250             miioCom.close();
251             this.miioCom = null;
252         }
253         miIoScheduler.shutdownNow();
254         logger.debug("Last Id saved for {}: {} -> {} ", getThing().getUID(), lastId,
255                 GSON.toJson(new SavedDeviceInfoDTO(lastId,
256                         (String) deviceVariables.getOrDefault(PROPERTY_DID, getThing().getUID().getId()))));
257         Utils.saveToFile("info_" + getThing().getUID().getId() + ".json", GSON.toJson(new SavedDeviceInfoDTO(lastId,
258                 (String) deviceVariables.getOrDefault(PROPERTY_DID, getThing().getUID().getId()))), logger);
259     }
260
261     protected int sendCommand(MiIoCommand command) {
262         return sendCommand(command, "[]");
263     }
264
265     protected int sendCommand(MiIoCommand command, String params) {
266         return sendCommand(command.getCommand(), processSubstitutions(params, deviceVariables), getCloudServer(), "");
267     }
268
269     protected int sendCommand(String commandString) {
270         return sendCommand(commandString, getCloudServer());
271     }
272
273     /**
274      * This is used to execute arbitrary commands by sending to the commands channel. Command parameters to be added
275      * between [] brackets. This to allow for unimplemented commands to be executed
276      *
277      * @param commandString command to be executed
278      * @param cloudServer server to be used or empty string for direct sending to the device
279      * @return message id
280      */
281     protected int sendCommand(String commandString, String cloudServer) {
282         String command = commandString.trim();
283         command = processSubstitutions(commandString.trim(), deviceVariables);
284         String param = "[]";
285         int sb = command.indexOf("[");
286         int cb = command.indexOf("{");
287         if (Math.max(sb, cb) > 0) {
288             int loc = (Math.min(sb, cb) > 0 ? Math.min(sb, cb) : Math.max(sb, cb));
289             param = command.substring(loc).trim();
290             command = command.substring(0, loc).trim();
291         }
292         return sendCommand(command, param, cloudServer, "");
293     }
294
295     protected int sendCommand(String command, String params, String cloudServer) {
296         return sendCommand(command, processSubstitutions(params, deviceVariables), cloudServer, "");
297     }
298
299     /**
300      * Sends commands to the {@link MiIoAsyncCommunication} for transmission to the Mi devices or cloud
301      *
302      * @param command (method) to be queued for execution
303      * @param params to be send with the command
304      * @param cloudServer server to be used or empty string for direct sending to the device
305      * @param sender subdevice or empty string for regular device
306      * @return message id
307      */
308     protected int sendCommand(String command, String params, String cloudServer, String sender) {
309         try {
310             if (!sender.isBlank()) {
311                 logger.debug("Received child command from {} : {} - {} (via: {})", sender, command, params,
312                         getThing().getUID());
313             }
314             final MiIoAsyncCommunication connection = getConnection();
315             return (connection != null) ? connection.queueCommand(command, params, cloudServer, sender) : 0;
316         } catch (MiIoCryptoException | IOException e) {
317             logger.debug("Command {} for {} failed (type: {}): {}", command, getThing().getUID(),
318                     getThing().getThingTypeUID(), e.getLocalizedMessage());
319             disconnected(e.getMessage());
320         }
321         return 0;
322     }
323
324     public String getCloudServer() {
325         // This can be improved in the future with additional / more advanced options like e.g. directFirst which would
326         // use direct communications and in case of failures fall back to cloud communication. For now we keep it
327         // simple and only have the option for cloud or direct.
328         final MiIoBindingConfiguration configuration = this.configuration;
329         if (configuration != null) {
330             return "cloud".equals(configuration.communication) ? cloudServer : "";
331         }
332         return "";
333     }
334
335     protected boolean skipUpdate() {
336         final MiIoAsyncCommunication miioCom = this.miioCom;
337         if (!hasConnection() || miioCom == null) {
338             logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
339             return true;
340         }
341         if (getThing().getStatusInfo().getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)) {
342             logger.debug("Skipping periodic update for '{}'. Thing Status {}", getThing().getUID().toString(),
343                     getThing().getStatusInfo().getStatusDetail());
344             sendCommand(MiIoCommand.MIIO_INFO);
345             return true;
346         }
347         if (miioCom.getQueueLength() > MAX_QUEUE) {
348             logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
349                     miioCom.getQueueLength());
350             return true;
351         }
352         return false;
353     }
354
355     protected abstract void updateData();
356
357     protected boolean updateNetwork(JsonObject networkData) {
358         try {
359             final MiIoInfoDTO miioInfo = GSON.fromJson(networkData, MiIoInfoDTO.class);
360             final MiIoInfoApDTO ap = miioInfo != null ? miioInfo.ap : null;
361             if (miioInfo != null && ap != null) {
362                 if (ap.getSsid() != null) {
363                     updateState(CHANNEL_SSID, new StringType(ap.getSsid()));
364                 }
365                 if (ap.getBssid() != null) {
366                     updateState(CHANNEL_BSSID, new StringType(ap.getBssid()));
367                 }
368                 if (ap.getRssi() != null) {
369                     updateState(CHANNEL_RSSI, new DecimalType(ap.getRssi()));
370                 } else if (ap.getWifiRssi() != null) {
371                     updateState(CHANNEL_RSSI, new DecimalType(ap.getWifiRssi()));
372                 } else {
373                     logger.debug("No RSSI info in response");
374                 }
375                 if (miioInfo.life != null) {
376                     updateState(CHANNEL_LIFE, new DecimalType(miioInfo.life));
377                 }
378             }
379             return true;
380         } catch (NumberFormatException e) {
381             logger.debug("Could not parse number in network response: {}", networkData);
382         } catch (JsonSyntaxException e) {
383             logger.debug("Could not parse network response: {}", networkData, e);
384         }
385         return false;
386     }
387
388     protected boolean hasConnection() {
389         return getConnection() != null;
390     }
391
392     protected void disconnectedNoResponse() {
393         disconnected("No Response from device");
394     }
395
396     protected void disconnected(@Nullable String message) {
397         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
398                 message != null ? message : "");
399         final MiIoAsyncCommunication miioCom = this.miioCom;
400         if (miioCom != null) {
401             lastId = miioCom.getId();
402             lastId += 10;
403         }
404     }
405
406     protected synchronized @Nullable MiIoAsyncCommunication getConnection() {
407         if (miioCom != null) {
408             return miioCom;
409         }
410         final MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
411         if (configuration.host.isBlank()) {
412             return null;
413         }
414         if (deviceId.isBlank() && !getCloudServer().isBlank()) {
415             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
416                     "@text/offline.config-error-cloud");
417             return null;
418         }
419         if (deviceId.length() == 8 && deviceId.matches("^.*[a-zA-Z]+.*$")) {
420             logger.warn(
421                     "As per openHAB version 3.2 the deviceId is no longer a string with hexadecimals, instead it is a string with the numeric respresentation of the deviceId. If you continue seeing this message, update deviceId in your thing configuration. Expected change for thing '{}': Update current deviceId: '{}' to '{}'",
422                     getThing().getUID(), deviceId, Utils.fromHEX(deviceId));
423             if (getCloudServer().isBlank()) {
424                 deviceId = "";
425             } else {
426                 final String id = Utils.fromHEX(deviceId);
427                 deviceId = id;
428                 miIoScheduler.execute(() -> updateDeviceIdConfig(id));
429             }
430         }
431
432         if (!deviceId.isBlank() && (tokenCheckPass(configuration.token) || !getCloudServer().isBlank())) {
433             final MiIoAsyncCommunication miioComF = new MiIoAsyncCommunication(configuration.host, token, deviceId,
434                     lastId, configuration.timeout, cloudConnector);
435             miioComF.registerListener(this);
436             this.miioCom = miioComF;
437             return miioComF;
438         } else {
439             logger.debug("No deviceId defined. Retrieving Mi deviceId");
440             final MiIoAsyncCommunication miioComF = new MiIoAsyncCommunication(configuration.host, token, "", lastId,
441                     configuration.timeout, cloudConnector);
442             try {
443                 Message miIoResponse = miioComF.sendPing(configuration.host);
444                 if (miIoResponse != null) {
445                     deviceId = Utils.fromHEX(Utils.getHex(miIoResponse.getDeviceId()));
446                     logger.debug("Ping response from deviceId '{}' at {}. Time stamp: {}, OH time {}, delta {}",
447                             deviceId, configuration.host, miIoResponse.getTimestamp(), LocalDateTime.now(),
448                             miioComF.getTimeDelta());
449                     miioComF.setDeviceId(deviceId);
450                     logger.debug("Using retrieved Mi deviceId: {}", deviceId);
451                     miioComF.registerListener(this);
452                     this.miioCom = miioComF;
453                     final String id = deviceId;
454                     miIoScheduler.execute(() -> updateDeviceIdConfig(id));
455                     return miioComF;
456                 } else {
457                     miioComF.close();
458                     logger.debug("Ping response from deviceId '{}' at {} FAILED", configuration.deviceId,
459                             configuration.host);
460                     disconnectedNoResponse();
461                     return null;
462                 }
463             } catch (IOException e) {
464                 miioComF.close();
465                 logger.debug("Could not connect to {} at {}", getThing().getUID().toString(), configuration.host);
466                 disconnected(e.getMessage());
467                 return null;
468             }
469
470         }
471     }
472
473     private void updateDeviceIdConfig(String deviceId) {
474         if (!deviceId.isEmpty()) {
475             updateProperty(Thing.PROPERTY_SERIAL_NUMBER, deviceId);
476             Configuration config = editConfiguration();
477             config.put(PROPERTY_DID, deviceId);
478             updateConfiguration(config);
479             deviceVariables.put(PROPERTY_DID, deviceId);
480         } else {
481             logger.debug("Could not update config with deviceId: {}", deviceId);
482         }
483     }
484
485     protected boolean initializeData() {
486         this.miioCom = getConnection();
487         return true;
488     }
489
490     protected void refreshNetwork() {
491         network.getValue();
492     }
493
494     protected void defineDeviceType(JsonObject miioInfo) {
495         updateProperties(miioInfo);
496         isIdentified = updateThingType(miioInfo);
497     }
498
499     private void updateProperties(JsonObject miioInfo) {
500         final MiIoInfoDTO info = GSON.fromJson(miioInfo, MiIoInfoDTO.class);
501         if (info == null) {
502             return;
503         }
504         Map<String, String> properties = editProperties();
505         if (info.model != null) {
506             properties.put(Thing.PROPERTY_MODEL_ID, info.model);
507         }
508         if (info.fwVer != null) {
509             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.fwVer);
510         }
511         if (info.hwVer != null) {
512             properties.put(Thing.PROPERTY_HARDWARE_VERSION, info.hwVer);
513         }
514         if (info.wifiFwVer != null) {
515             properties.put("wifiFirmware", info.wifiFwVer);
516         }
517         if (info.mcuFwVer != null) {
518             properties.put("mcuFirmware", info.mcuFwVer);
519         }
520         deviceVariables.putAll(properties);
521         updateProperties(properties);
522     }
523
524     protected String processSubstitutions(String cmd, Map<String, Object> deviceVariables) {
525         if (!cmd.contains("$")) {
526             return cmd;
527         }
528         String returnCmd = cmd.replace("\"$", "$").replace("$\"", "$");
529         String[] cmdParts = cmd.split("\\$");
530         if (logger.isTraceEnabled()) {
531             logger.debug("processSubstitutions {} ", cmd);
532             for (Entry<String, Object> e : deviceVariables.entrySet()) {
533                 logger.debug("key, value:  {}  -> {}", e.getKey(), e.getValue());
534             }
535         }
536         for (String substitute : cmdParts) {
537             if (deviceVariables.containsKey(substitute)) {
538                 String replacementString = "";
539                 Object replacement = deviceVariables.get(substitute);
540                 if (replacement == null) {
541                     logger.debug("Replacement for '{}' is null. skipping replacement", substitute);
542                     continue;
543                 }
544                 if (replacement instanceof Integer || replacement instanceof Long || replacement instanceof Double
545                         || replacement instanceof BigDecimal || replacement instanceof Boolean) {
546                     replacementString = replacement.toString();
547                 } else if (replacement instanceof JsonPrimitive) {
548                     replacementString = ((JsonPrimitive) replacement).getAsString();
549                 } else if (replacement instanceof String) {
550                     replacementString = "\"" + (String) replacement + "\"";
551                 } else {
552                     replacementString = String.valueOf(replacement);
553                 }
554                 returnCmd = returnCmd.replace("$" + substitute + "$", replacementString);
555             }
556         }
557         return returnCmd;
558     }
559
560     protected boolean updateThingType(JsonObject miioInfo) {
561         MiIoBindingConfiguration configuration = getConfigAs(MiIoBindingConfiguration.class);
562         String model = miioInfo.get("model").getAsString();
563         miDevice = MiIoDevices.getType(model);
564         if (configuration.model.isEmpty()) {
565             Configuration config = editConfiguration();
566             config.put(PROPERTY_MODEL, model);
567             updateConfiguration(config);
568             configuration = getConfigAs(MiIoBindingConfiguration.class);
569         }
570         if (!configuration.model.equals(model)) {
571             logger.info("Mi Device model {} has model config: {}. Unexpected unless manual override", model,
572                     configuration.model);
573         }
574         if (miDevice.getThingType().equals(getThing().getThingTypeUID())
575                 && !(miDevice.getThingType().equals(THING_TYPE_UNSUPPORTED)
576                         && miIoDatabaseWatchService.getDatabaseUrl(model) != null)) {
577             logger.debug("Mi Device model {} identified as: {}. Matches thingtype {}", model, miDevice.toString(),
578                     miDevice.getThingType().toString());
579             return true;
580         } else {
581             if (getThing().getThingTypeUID().equals(THING_TYPE_MIIO)
582                     || getThing().getThingTypeUID().equals(THING_TYPE_UNSUPPORTED)) {
583                 changeType(model);
584             } else {
585                 logger.info(
586                         "Mi Device model {} identified as: {}, thingtype {}. Does not matches thingtype {}. Unexpected, unless manual override.",
587                         miDevice.toString(), miDevice.getThingType(), getThing().getThingTypeUID().toString(),
588                         miDevice.getThingType().toString());
589                 return true;
590             }
591         }
592         return false;
593     }
594
595     /**
596      * Changes the {@link org.openhab.core.thing.type.ThingType} to the right type once it is retrieved from
597      * the device.
598      *
599      * @param modelId String with the model id
600      */
601     private void changeType(final String modelId) {
602         final ScheduledFuture<?> pollingJob = this.pollingJob;
603         if (pollingJob != null) {
604             pollingJob.cancel(true);
605             this.pollingJob = null;
606         }
607         miIoScheduler.schedule(() -> {
608             String label = getThing().getLabel();
609             if (label == null || label.startsWith("Xiaomi Mi Device")) {
610                 ThingBuilder thingBuilder = editThing();
611                 label = getLocalText(I18N_THING_PREFIX + modelId, miDevice.getDescription());
612                 thingBuilder.withLabel(label);
613                 updateThing(thingBuilder.build());
614             }
615             logger.info("Mi Device model {} identified as: {}. Does not match thingtype {}. Changing thingtype to {}",
616                     modelId, miDevice.toString(), getThing().getThingTypeUID().toString(),
617                     miDevice.getThingType().toString());
618             ThingTypeUID thingTypeUID = MiIoDevices.getType(modelId).getThingType();
619             if (thingTypeUID.equals(THING_TYPE_UNSUPPORTED)
620                     && miIoDatabaseWatchService.getDatabaseUrl(modelId) != null) {
621                 thingTypeUID = THING_TYPE_BASIC;
622             }
623             changeThingType(thingTypeUID, getConfig());
624         }, 10, TimeUnit.SECONDS);
625     }
626
627     @Override
628     public void onStatusUpdated(ThingStatus status, ThingStatusDetail statusDetail) {
629         updateStatus(status, statusDetail);
630     }
631
632     @Override
633     public void onMessageReceived(MiIoSendCommand response) {
634         if (!response.getSender().isBlank() && !response.getSender().contentEquals(getThing().getUID().getAsString())) {
635             for (Entry<Thing, MiIoLumiHandler> entry : childDevices.entrySet()) {
636                 if (entry.getKey().getUID().getAsString().contentEquals(response.getSender())) {
637                     logger.trace("Submit response to to child {} -> {}", response.getSender(), entry.getKey().getUID());
638                     entry.getValue().onMessageReceived(response);
639                     return;
640                 }
641             }
642             logger.debug("{} Could not find match in {} child devices for submitter {}", getThing().getUID(),
643                     childDevices.size(), response.getSender());
644             return;
645         }
646
647         logger.debug("Received response for device {} type: {}, result: {}, fullresponse: {}",
648                 getThing().getUID().getId(),
649                 MiIoCommand.UNKNOWN.equals(response.getCommand())
650                         ? response.getCommand().toString() + "(" + response.getCommandString() + ")"
651                         : response.getCommand(),
652                 response.getResult(), response.getResponse());
653         if (response.isError()) {
654             logger.debug("Error received for command '{}': {}.", response.getCommandString(),
655                     response.getResponse().get("error"));
656             if (MiIoCommand.MIIO_INFO.equals(response.getCommand())) {
657                 network.invalidateValue();
658             }
659             return;
660         }
661         try {
662             switch (response.getCommand()) {
663                 case MIIO_INFO:
664                     if (!isIdentified) {
665                         defineDeviceType(response.getResult().getAsJsonObject());
666                     }
667                     updateNetwork(response.getResult().getAsJsonObject());
668                     break;
669                 default:
670                     break;
671             }
672             if (cmds.containsKey(response.getId())) {
673                 String channel = cmds.get(response.getId());
674                 if (channel != null && (CHANNEL_COMMAND.contentEquals(channel) || CHANNEL_RPC.contentEquals(channel))) {
675                     updateState(channel, new StringType(response.getResponse().toString()));
676                     cmds.remove(response.getId());
677                 }
678             }
679         } catch (Exception e) {
680             logger.debug("Error while handing message {}", response.getResponse(), e);
681         }
682     }
683
684     protected String getLocalText(String key, String defaultText) {
685         try {
686             String text = i18nProvider.getText(bundle, key, defaultText, localeProvider.getLocale());
687             return text != null ? text : defaultText;
688         } catch (IllegalArgumentException e) {
689             return defaultText;
690         }
691     }
692 }