]> git.basschouten.com Git - openhab-addons.git/blob
cd076a9d5dde15c49041e8c17887b3a76dd282f4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.miio.internal.cloud;
14
15 import java.io.IOException;
16 import java.net.CookieStore;
17 import java.net.HttpCookie;
18 import java.net.MalformedURLException;
19 import java.net.URI;
20 import java.net.URL;
21 import java.time.ZonedDateTime;
22 import java.time.format.DateTimeFormatter;
23 import java.util.ArrayList;
24 import java.util.Date;
25 import java.util.HashMap;
26 import java.util.List;
27 import java.util.Locale;
28 import java.util.Map;
29 import java.util.Random;
30 import java.util.TimeZone;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.TimeUnit;
33 import java.util.concurrent.TimeoutException;
34
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jetty.client.HttpClient;
37 import org.eclipse.jetty.client.HttpResponseException;
38 import org.eclipse.jetty.client.api.ContentResponse;
39 import org.eclipse.jetty.client.api.Request;
40 import org.eclipse.jetty.client.util.FormContentProvider;
41 import org.eclipse.jetty.http.HttpHeader;
42 import org.eclipse.jetty.http.HttpMethod;
43 import org.eclipse.jetty.http.HttpStatus;
44 import org.eclipse.jetty.util.Fields;
45 import org.openhab.binding.miio.internal.MiIoCrypto;
46 import org.openhab.binding.miio.internal.MiIoCryptoException;
47 import org.openhab.binding.miio.internal.Utils;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.google.gson.Gson;
52 import com.google.gson.GsonBuilder;
53 import com.google.gson.JsonElement;
54 import com.google.gson.JsonObject;
55 import com.google.gson.JsonParseException;
56 import com.google.gson.JsonParser;
57 import com.google.gson.JsonSyntaxException;
58
59 /**
60  * The {@link MiCloudConnector} class is used for connecting to the Xiaomi cloud access
61  *
62  * @author Marcel Verpaalen - Initial contribution
63  */
64 @NonNullByDefault
65 public class MiCloudConnector {
66
67     private static final int REQUEST_TIMEOUT_SECONDS = 10;
68     private static final String UNEXPECTED = "Unexpected :";
69     private static final String AGENT_ID = (new Random().ints(65, 70).limit(13)
70             .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
71     private static final String USERAGENT = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-" + AGENT_ID
72             + " APP/xiaomi.smarthome APPV/62830";
73     private static Locale locale = Locale.getDefault();
74     private static final TimeZone TZ = TimeZone.getDefault();
75     private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("OOOO");
76     private static final Gson GSON = new GsonBuilder().serializeNulls().create();
77     private static final JsonParser PARSER = new JsonParser();
78
79     private final String clientId;
80
81     private String username;
82     private String password;
83     private String userId = "";
84     private String serviceToken = "";
85     private String ssecurity = "";
86     private int loginFailedCounter = 0;
87     private HttpClient httpClient;
88
89     private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class);
90
91     public MiCloudConnector(String username, String password, HttpClient httpClient) throws MiCloudException {
92         this.username = username;
93         this.password = password;
94         this.httpClient = httpClient;
95         if (!checkCredentials()) {
96             throw new MiCloudException("username or password can't be empty");
97         }
98         clientId = (new Random().ints(97, 122 + 1).limit(6)
99                 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
100     }
101
102     void startClient() throws MiCloudException {
103         if (!httpClient.isStarted()) {
104             try {
105                 httpClient.start();
106                 CookieStore cookieStore = httpClient.getCookieStore();
107                 // set default cookies
108                 addCookie(cookieStore, "sdkVersion", "accountsdk-18.8.15", "mi.com");
109                 addCookie(cookieStore, "sdkVersion", "accountsdk-18.8.15", "xiaomi.com");
110                 addCookie(cookieStore, "deviceId", this.clientId, "mi.com");
111                 addCookie(cookieStore, "deviceId", this.clientId, "xiaomi.com");
112             } catch (Exception e) {
113                 throw new MiCloudException("No http client cannot be started: " + e.getMessage(), e);
114             }
115         }
116     }
117
118     public void stopClient() {
119         try {
120             this.httpClient.stop();
121         } catch (Exception e) {
122             logger.debug("Error stopping httpclient :{}", e.getMessage(), e);
123         }
124     }
125
126     private boolean checkCredentials() {
127         if (username.trim().isEmpty() || password.trim().isEmpty()) {
128             logger.info("Xiaomi Cloud: username or password missing.");
129             return false;
130         }
131         return true;
132     }
133
134     private String getApiUrl(String country) {
135         return "https://" + (country.trim().equalsIgnoreCase("cn") ? "" : country.trim().toLowerCase() + ".")
136                 + "api.io.mi.com/app";
137     }
138
139     public String getClientId() {
140         return clientId;
141     }
142
143     String parseJson(String data) {
144         if (data.contains("&&&START&&&")) {
145             return data.replace("&&&START&&&", "");
146         } else {
147             return UNEXPECTED.concat(data);
148         }
149     }
150
151     public String getMapUrl(String vacuumMap, String country) throws MiCloudException {
152         String url = getApiUrl(country) + "/home/getmapfileurl";
153         Map<String, String> map = new HashMap<String, String>();
154         map.put("data", "{\"obj_name\":\"" + vacuumMap + "\"}");
155         String mapResponse = request(url, map);
156         logger.trace("response: {}", mapResponse);
157         String errorMsg = "";
158         try {
159             JsonElement response = PARSER.parse(mapResponse);
160             if (response.isJsonObject()) {
161                 logger.debug("Received  JSON message {}", response);
162                 if (response.getAsJsonObject().has("result")
163                         && response.getAsJsonObject().get("result").isJsonObject()) {
164                     JsonObject jo = response.getAsJsonObject().get("result").getAsJsonObject();
165                     if (jo.has("url")) {
166                         return jo.get("url").getAsString();
167                     } else {
168                         errorMsg = "Could not get url";
169                     }
170                 } else {
171                     errorMsg = "Could not get result";
172                 }
173             } else {
174                 errorMsg = "Received message is invalid JSON";
175             }
176         } catch (ClassCastException | IllegalStateException e) {
177             errorMsg = "Received message could not be parsed";
178         }
179         logger.debug("{}: {}", errorMsg, mapResponse);
180         return "";
181     }
182
183     public String getDeviceStatus(String device, String country) throws MiCloudException {
184         final String response = request("/home/device_list", country, "{\"dids\":[\"" + device + "\"]}");
185         logger.debug("response: {}", response);
186         return response;
187     }
188
189     public String sendRPCCommand(String device, String country, String command) throws MiCloudException {
190         if (device.length() != 8) {
191             logger.debug("Device ID ('{}') incorrect or missing. Command not send: {}", device, command);
192         }
193         if (country.length() > 3 || country.length() < 2) {
194             logger.debug("Country ('{}') incorrect or missing. Command not send: {}", device, command);
195         }
196         String id = "";
197         try {
198             id = String.valueOf(Long.parseUnsignedLong(device, 16));
199         } catch (NumberFormatException e) {
200             String err = "Could not parse device ID ('" + device.toString() + "')";
201             logger.debug("{}", err);
202             throw new MiCloudException(err, e);
203         }
204         final String response = request("/home/rpc/" + id, country, command);
205         logger.debug("response: {}", response);
206         return response;
207     }
208
209     public List<CloudDeviceDTO> getDevices(String country) {
210         final String response = getDeviceString(country);
211         List<CloudDeviceDTO> devicesList = new ArrayList<>();
212         try {
213             final JsonElement resp = PARSER.parse(response);
214             if (resp.isJsonObject()) {
215                 final JsonObject jor = resp.getAsJsonObject();
216                 if (jor.has("result")) {
217                     CloudDeviceListDTO cdl = GSON.fromJson(jor.get("result"), CloudDeviceListDTO.class);
218                     if (cdl != null) {
219                         devicesList.addAll(cdl.getCloudDevices());
220                         for (CloudDeviceDTO device : devicesList) {
221                             device.setServer(country);
222                             logger.debug("Xiaomi cloud info: {}", device);
223                         }
224                     }
225                 } else {
226                     logger.debug("Response missing result: '{}'", response);
227                 }
228             } else {
229                 logger.debug("Response is not a json object: '{}'", response);
230             }
231         } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
232             loginFailedCounter++;
233             logger.info("Error while parsing devices: {}", e.getMessage());
234         }
235         return devicesList;
236     }
237
238     public String getDeviceString(String country) {
239         String resp;
240         try {
241             resp = request("/home/device_list", country, "{\"getVirtualModel\":false,\"getHuamiDevices\":0}");
242             logger.trace("Get devices response: {}", resp);
243             if (resp.length() > 2) {
244                 CloudUtil.saveDeviceInfoFile(resp, country, logger);
245                 return resp;
246             }
247         } catch (MiCloudException e) {
248             logger.info("{}", e.getMessage());
249             loginFailedCounter++;
250         }
251         return "";
252     }
253
254     public String request(String urlPart, String country, String params) throws MiCloudException {
255         Map<String, String> map = new HashMap<String, String>();
256         map.put("data", params);
257         return request(urlPart, country, map);
258     }
259
260     public String request(String urlPart, String country, Map<String, String> params) throws MiCloudException {
261         String url = urlPart.trim();
262         url = getApiUrl(country) + (url.startsWith("/app") ? url.substring(4) : url);
263         String response = request(url, params);
264         logger.debug("Request to {} server {}. Response: {}", country, urlPart, response);
265         return response;
266     }
267
268     public String request(String url, Map<String, String> params) throws MiCloudException {
269         if (this.serviceToken.isEmpty() || this.userId.isEmpty()) {
270             throw new MiCloudException("Cannot execute request. service token or userId missing");
271         }
272         loginFailedCounterCheck();
273         startClient();
274         logger.debug("Send request: {} to {}", params.get("data"), url);
275         Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
276         request.agent(USERAGENT);
277         request.header("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2");
278         request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
279         request.cookie(new HttpCookie("userId", this.userId));
280         request.cookie(new HttpCookie("yetAnotherServiceToken", this.serviceToken));
281         request.cookie(new HttpCookie("serviceToken", this.serviceToken));
282         request.cookie(new HttpCookie("locale", locale.toString()));
283         request.cookie(new HttpCookie("timezone", ZonedDateTime.now().format(FORMATTER)));
284         request.cookie(new HttpCookie("is_daylight", TZ.inDaylightTime(new Date()) ? "1" : "0"));
285         request.cookie(new HttpCookie("dst_offset", Integer.toString(TZ.getDSTSavings())));
286         request.cookie(new HttpCookie("channel", "MI_APP_STORE"));
287
288         if (logger.isTraceEnabled()) {
289             for (HttpCookie cookie : request.getCookies()) {
290                 logger.trace("Cookie set for request ({}) : {} --> {}     (path: {})", cookie.getDomain(),
291                         cookie.getName(), cookie.getValue(), cookie.getPath());
292             }
293         }
294         String method = "POST";
295         request.method(method);
296
297         try {
298             String nonce = CloudUtil.generateNonce(System.currentTimeMillis());
299             String signedNonce = CloudUtil.signedNonce(ssecurity, nonce);
300             String signature = CloudUtil.generateSignature(url.replace("/app", ""), signedNonce, nonce, params);
301
302             Fields fields = new Fields();
303             fields.put("signature", signature);
304             fields.put("_nonce", nonce);
305             fields.put("data", params.get("data"));
306             request.content(new FormContentProvider(fields));
307
308             logger.trace("fieldcontent: {}", fields.toString());
309             final ContentResponse response = request.send();
310             if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
311                     && response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
312                 this.serviceToken = "";
313             }
314             return response.getContentAsString();
315         } catch (HttpResponseException e) {
316             serviceToken = "";
317             logger.debug("Error while executing request to {} :{}", url, e.getMessage());
318             loginFailedCounter++;
319         } catch (InterruptedException | TimeoutException | ExecutionException | IOException e) {
320             logger.debug("Error while executing request to {} :{}", url, e.getMessage());
321             loginFailedCounter++;
322         } catch (MiIoCryptoException e) {
323             logger.debug("Error while decrypting response of request to {} :{}", url, e.getMessage(), e);
324             loginFailedCounter++;
325         }
326         return "";
327     }
328
329     private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
330         HttpCookie cookie = new HttpCookie(name, value);
331         cookie.setDomain("." + domain);
332         cookie.setPath("/");
333         cookieStore.add(URI.create("https://" + domain), cookie);
334     }
335
336     public synchronized boolean login() {
337         if (!checkCredentials()) {
338             return false;
339         }
340         if (!userId.isEmpty() && !serviceToken.isEmpty()) {
341             return true;
342         }
343         logger.debug("Xiaomi cloud login with userid {}", username);
344         try {
345             if (loginRequest()) {
346                 loginFailedCounter = 0;
347             } else {
348                 loginFailedCounter++;
349                 logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
350             }
351         } catch (MiCloudException e) {
352             logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
353             loginFailedCounter++;
354             serviceToken = "";
355             loginFailedCounterCheck();
356             return false;
357         }
358         return true;
359     }
360
361     void loginFailedCounterCheck() {
362         if (loginFailedCounter > 10) {
363             logger.info("Repeated errors logging on to Xiaomi cloud. Cleaning stored cookies");
364             dumpCookies(".xiaomi.com", true);
365             dumpCookies(".mi.com", true);
366             serviceToken = "";
367             loginFailedCounter = 0;
368         }
369     }
370
371     protected boolean loginRequest() throws MiCloudException {
372         try {
373             startClient();
374             String sign = loginStep1();
375             String location;
376             if (!sign.startsWith("http")) {
377                 location = loginStep2(sign);
378             } else {
379                 location = sign; // seems we already have login location
380             }
381             final ContentResponse responseStep3 = loginStep3(location);
382             switch (responseStep3.getStatus()) {
383                 case HttpStatus.FORBIDDEN_403:
384                     throw new MiCloudException("Access denied. Did you set the correct api-key and/or username?");
385                 case HttpStatus.OK_200:
386                     return true;
387                 default:
388                     logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
389                             responseStep3.getReason(), responseStep3.getContentAsString());
390                     throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
391             }
392         } catch (InterruptedException | TimeoutException | ExecutionException e) {
393             throw new MiCloudException("Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
394         } catch (MiIoCryptoException e) {
395             throw new MiCloudException("Error decrypting. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
396         } catch (MalformedURLException | JsonParseException e) {
397             throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
398         }
399     }
400
401     private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
402         final ContentResponse responseStep1;
403
404         logger.trace("Xiaomi Login step 1");
405         String url = "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true";
406         Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
407         request.agent(USERAGENT);
408         request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
409         request.cookie(new HttpCookie("userId", this.userId.length() > 0 ? this.userId : this.username));
410
411         responseStep1 = request.send();
412         final String content = responseStep1.getContentAsString();
413         logger.trace("Xiaomi Login step 1 content response= {}", content);
414         logger.trace("Xiaomi Login step 1 response = {}", responseStep1);
415         try {
416             JsonElement resp = new JsonParser().parse(parseJson(content));
417             if (resp.isJsonObject() && resp.getAsJsonObject().has("_sign")) {
418                 String sign = resp.getAsJsonObject().get("_sign").getAsString();
419                 logger.trace("Xiaomi Login step 1 sign = {}", sign);
420                 return sign;
421             } else {
422                 logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
423                 return "";
424             }
425         } catch (JsonParseException | IllegalStateException | ClassCastException e) {
426             throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e);
427         }
428     }
429
430     private String loginStep2(String sign) throws MiIoCryptoException, InterruptedException, TimeoutException,
431             ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
432         String passToken;
433         String cUserId;
434
435         logger.trace("Xiaomi Login step 2");
436         String url = "https://account.xiaomi.com/pass/serviceLoginAuth2";
437         Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
438         request.agent(USERAGENT);
439         request.method(HttpMethod.POST);
440         final ContentResponse responseStep2;
441
442         Fields fields = new Fields();
443         fields.put("sid", "xiaomiio");
444         fields.put("hash", Utils.getHex(MiIoCrypto.md5(password.getBytes())));
445         fields.put("callback", "https://sts.api.io.mi.com/sts");
446         fields.put("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue");
447         fields.put("user", username);
448         if (!sign.isEmpty()) {
449             fields.put("_sign", sign);
450         }
451         fields.put("_json", "true");
452
453         request.content(new FormContentProvider(fields));
454         responseStep2 = request.send();
455
456         final String content2 = responseStep2.getContentAsString();
457         logger.trace("Xiaomi login step 2 response = {}", responseStep2);
458         logger.trace("Xiaomi login step 2 content = {}", content2);
459
460         JsonElement resp2 = new JsonParser().parse(parseJson(content2));
461         CloudLoginDTO jsonResp = GSON.fromJson(resp2, CloudLoginDTO.class);
462         if (jsonResp == null) {
463             throw new MiCloudException("Error getting logon details from step 2: " + content2);
464         }
465         ssecurity = jsonResp.getSsecurity();
466         userId = jsonResp.getUserId();
467         cUserId = jsonResp.getcUserId();
468         passToken = jsonResp.getPassToken();
469         String location = jsonResp.getLocation();
470         String code = jsonResp.getCode();
471
472         logger.trace("Xiaomi login ssecurity = {}", ssecurity);
473         logger.trace("Xiaomi login userId = {}", userId);
474         logger.trace("Xiaomi login cUserId = {}", cUserId);
475         logger.trace("Xiaomi login passToken = {}", passToken);
476         logger.trace("Xiaomi login location = {}", location);
477         logger.trace("Xiaomi login code = {}", code);
478         if (logger.isTraceEnabled()) {
479             dumpCookies(url, false);
480         }
481         if (!location.isEmpty()) {
482             return location;
483         } else {
484             throw new MiCloudException("Error getting logon location URL. Return code: " + code);
485         }
486     }
487
488     private ContentResponse loginStep3(String location)
489             throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
490         final ContentResponse responseStep3;
491         Request request;
492         logger.trace("Xiaomi Login step 3 @ {}", (new URL(location)).getHost());
493         request = httpClient.newRequest(location).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
494         request.agent(USERAGENT);
495         request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
496         responseStep3 = request.send();
497         logger.trace("Xiaomi login step 3 content = {}", responseStep3.getContentAsString());
498         logger.trace("Xiaomi login step 3 response = {}", responseStep3);
499         if (logger.isTraceEnabled()) {
500             dumpCookies(location, false);
501         }
502         URI uri = URI.create("http://sts.api.io.mi.com");
503         String serviceToken = extractServiceToken(uri);
504         if (!serviceToken.isEmpty()) {
505             this.serviceToken = serviceToken;
506         }
507         return responseStep3;
508     }
509
510     private void dumpCookies(String url, boolean delete) {
511         if (logger.isTraceEnabled()) {
512             try {
513                 URI uri = URI.create(url);
514                 logger.trace("Cookie dump for {}", uri);
515                 CookieStore cs = httpClient.getCookieStore();
516                 if (cs != null) {
517                     List<HttpCookie> cookies = cs.get(uri);
518                     for (HttpCookie cookie : cookies) {
519                         logger.trace("Cookie ({}) : {} --> {}     (path: {}. Removed: {})", cookie.getDomain(),
520                                 cookie.getName(), cookie.getValue(), cookie.getPath(), delete);
521                         if (delete) {
522                             cs.remove(uri, cookie);
523                         }
524                     }
525                 } else {
526                     logger.trace("Could not create cookiestore from {}", url);
527                 }
528             } catch (IllegalArgumentException e) {
529                 logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e);
530             }
531         }
532     }
533
534     private String extractServiceToken(URI uri) {
535         String serviceToken = "";
536         List<HttpCookie> cookies = httpClient.getCookieStore().get(uri);
537         for (HttpCookie cookie : cookies) {
538             logger.trace("Cookie :{} --> {}", cookie.getName(), cookie.getValue());
539             if (cookie.getName().contentEquals("serviceToken")) {
540                 serviceToken = cookie.getValue();
541                 logger.debug("Xiaomi cloud logon succesfull.");
542                 logger.trace("Xiaomi cloud servicetoken: {}", serviceToken);
543             }
544         }
545         return serviceToken;
546     }
547
548     public boolean hasLoginToken() {
549         return !serviceToken.isEmpty();
550     }
551 }