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