]> git.basschouten.com Git - openhab-addons.git/blob
302de9d21979d0f518f099b3b3ddb06f26a21114
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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
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         try {
158             JsonElement response = JsonParser.parseString(mapResponse);
159             if (response.isJsonObject()) {
160                 logger.debug("Received  JSON message {}", response);
161                 if (response.getAsJsonObject().has("result")
162                         && response.getAsJsonObject().get("result").isJsonObject()) {
163                     JsonObject jo = response.getAsJsonObject().get("result").getAsJsonObject();
164                     if (jo.has("url")) {
165                         return jo.get("url").getAsString();
166                     } else {
167                         errorMsg = "Could not get url";
168                     }
169                 } else {
170                     errorMsg = "Could not get result";
171                 }
172             } else {
173                 errorMsg = "Received message is invalid JSON";
174             }
175         } catch (ClassCastException | IllegalStateException e) {
176             errorMsg = "Received message could not be parsed";
177         }
178         logger.debug("{}: {}", errorMsg, mapResponse);
179         return "";
180     }
181
182     public String getDeviceStatus(String device, String country) throws MiCloudException {
183         final String response = request("/home/device_list", country, "{\"dids\":[\"" + device + "\"]}");
184         return response;
185     }
186
187     public String sendRPCCommand(String device, String country, String command) throws MiCloudException {
188         if (device.length() != 8) {
189             logger.debug("Device ID ('{}') incorrect or missing. Command not send: {}", device, command);
190         }
191         if (country.length() > 3 || country.length() < 2) {
192             logger.debug("Country ('{}') incorrect or missing. Command not send: {}", device, command);
193         }
194         String id = "";
195         try {
196             id = String.valueOf(Long.parseUnsignedLong(device, 16));
197         } catch (NumberFormatException e) {
198             String err = "Could not parse device ID ('" + device.toString() + "')";
199             logger.debug("{}", err);
200             throw new MiCloudException(err, e);
201         }
202         final String response = request("/home/rpc/" + id, country, command);
203         return response;
204     }
205
206     public List<CloudDeviceDTO> getDevices(String country) {
207         final String response = getDeviceString(country);
208         List<CloudDeviceDTO> devicesList = new ArrayList<>();
209         try {
210             final JsonElement resp = JsonParser.parseString(response);
211             if (resp.isJsonObject()) {
212                 final JsonObject jor = resp.getAsJsonObject();
213                 if (jor.has("result")) {
214                     CloudDeviceListDTO cdl = GSON.fromJson(jor.get("result"), CloudDeviceListDTO.class);
215                     if (cdl != null) {
216                         devicesList.addAll(cdl.getCloudDevices());
217                         for (CloudDeviceDTO device : devicesList) {
218                             device.setServer(country);
219                             logger.debug("Xiaomi cloud info: {}", device);
220                         }
221                     }
222                 } else {
223                     logger.debug("Response missing result: '{}'", response);
224                 }
225             } else {
226                 logger.debug("Response is not a json object: '{}'", response);
227             }
228         } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
229             loginFailedCounter++;
230             logger.info("Error while parsing devices: {}", e.getMessage());
231         }
232         return devicesList;
233     }
234
235     public String getDeviceString(String country) {
236         String resp;
237         try {
238             resp = request("/home/device_list_page", country, "{\"getVirtualModel\":true,\"getHuamiDevices\":1}");
239             logger.trace("Get devices response: {}", resp);
240             if (resp.length() > 2) {
241                 CloudUtil.saveDeviceInfoFile(resp, country, logger);
242                 return resp;
243             }
244         } catch (MiCloudException e) {
245             logger.info("{}", e.getMessage());
246             loginFailedCounter++;
247         }
248         return "";
249     }
250
251     public String request(String urlPart, String country, String params) throws MiCloudException {
252         Map<String, String> map = new HashMap<String, String>();
253         map.put("data", params);
254         return request(urlPart, country, map);
255     }
256
257     public String request(String urlPart, String country, Map<String, String> params) throws MiCloudException {
258         String url = urlPart.trim();
259         url = getApiUrl(country) + (url.startsWith("/app") ? url.substring(4) : url);
260         String response = request(url, params);
261         logger.debug("Request to '{}' server '{}'. Response: '{}'", country, urlPart, response);
262         return response;
263     }
264
265     public String request(String url, Map<String, String> params) throws MiCloudException {
266         if (this.serviceToken.isEmpty() || this.userId.isEmpty()) {
267             throw new MiCloudException("Cannot execute request. service token or userId missing");
268         }
269         loginFailedCounterCheck();
270         startClient();
271         logger.debug("Send request to {} with data '{}'", url, params.get("data"));
272         Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
273         request.agent(USERAGENT);
274         request.header("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2");
275         request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
276         request.cookie(new HttpCookie("userId", this.userId));
277         request.cookie(new HttpCookie("yetAnotherServiceToken", this.serviceToken));
278         request.cookie(new HttpCookie("serviceToken", this.serviceToken));
279         request.cookie(new HttpCookie("locale", locale.toString()));
280         request.cookie(new HttpCookie("timezone", ZonedDateTime.now().format(FORMATTER)));
281         request.cookie(new HttpCookie("is_daylight", TZ.inDaylightTime(new Date()) ? "1" : "0"));
282         request.cookie(new HttpCookie("dst_offset", Integer.toString(TZ.getDSTSavings())));
283         request.cookie(new HttpCookie("channel", "MI_APP_STORE"));
284
285         if (logger.isTraceEnabled()) {
286             for (HttpCookie cookie : request.getCookies()) {
287                 logger.trace("Cookie set for request ({}) : {} --> {}     (path: {})", cookie.getDomain(),
288                         cookie.getName(), cookie.getValue(), cookie.getPath());
289             }
290         }
291         String method = "POST";
292         request.method(method);
293
294         try {
295             String nonce = CloudUtil.generateNonce(System.currentTimeMillis());
296             String signedNonce = CloudUtil.signedNonce(ssecurity, nonce);
297             String signature = CloudUtil.generateSignature(url.replace("/app", ""), signedNonce, nonce, params);
298
299             Fields fields = new Fields();
300             fields.put("signature", signature);
301             fields.put("_nonce", nonce);
302             fields.put("data", params.get("data"));
303             request.content(new FormContentProvider(fields));
304
305             logger.trace("fieldcontent: {}", fields.toString());
306             final ContentResponse response = request.send();
307             if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
308                     && response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
309                 this.serviceToken = "";
310             }
311             return response.getContentAsString();
312         } catch (HttpResponseException e) {
313             serviceToken = "";
314             logger.debug("Error while executing request to {} :{}", url, e.getMessage());
315             loginFailedCounter++;
316         } catch (InterruptedException | TimeoutException | ExecutionException | IOException e) {
317             logger.debug("Error while executing request to {} :{}", url, e.getMessage());
318             loginFailedCounter++;
319         } catch (MiIoCryptoException e) {
320             logger.debug("Error while decrypting response of request to {} :{}", url, e.getMessage(), e);
321             loginFailedCounter++;
322         }
323         return "";
324     }
325
326     private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
327         HttpCookie cookie = new HttpCookie(name, value);
328         cookie.setDomain("." + domain);
329         cookie.setPath("/");
330         cookieStore.add(URI.create("https://" + domain), cookie);
331     }
332
333     public synchronized boolean login() {
334         if (!checkCredentials()) {
335             return false;
336         }
337         if (!userId.isEmpty() && !serviceToken.isEmpty()) {
338             return true;
339         }
340         logger.debug("Xiaomi cloud login with userid {}", username);
341         try {
342             if (loginRequest()) {
343                 loginFailedCounter = 0;
344             } else {
345                 loginFailedCounter++;
346                 logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
347             }
348         } catch (MiCloudException e) {
349             logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
350             loginFailedCounter++;
351             serviceToken = "";
352             loginFailedCounterCheck();
353             return false;
354         }
355         return true;
356     }
357
358     void loginFailedCounterCheck() {
359         if (loginFailedCounter > 10) {
360             logger.info("Repeated errors logging on to Xiaomi cloud. Cleaning stored cookies");
361             dumpCookies(".xiaomi.com", true);
362             dumpCookies(".mi.com", true);
363             serviceToken = "";
364             loginFailedCounter = 0;
365         }
366     }
367
368     protected boolean loginRequest() throws MiCloudException {
369         try {
370             startClient();
371             String sign = loginStep1();
372             String location;
373             if (!sign.startsWith("http")) {
374                 location = loginStep2(sign);
375             } else {
376                 location = sign; // seems we already have login location
377             }
378             final ContentResponse responseStep3 = loginStep3(location);
379             switch (responseStep3.getStatus()) {
380                 case HttpStatus.FORBIDDEN_403:
381                     throw new MiCloudException("Access denied. Did you set the correct api-key and/or username?");
382                 case HttpStatus.OK_200:
383                     return true;
384                 default:
385                     logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
386                             responseStep3.getReason(), responseStep3.getContentAsString());
387                     throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
388             }
389         } catch (InterruptedException | TimeoutException | ExecutionException e) {
390             throw new MiCloudException("Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
391         } catch (MiIoCryptoException e) {
392             throw new MiCloudException("Error decrypting. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
393         } catch (MalformedURLException | JsonParseException e) {
394             throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
395         }
396     }
397
398     private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
399         final ContentResponse responseStep1;
400
401         logger.trace("Xiaomi Login step 1");
402         String url = "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true";
403         Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
404         request.agent(USERAGENT);
405         request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
406         request.cookie(new HttpCookie("userId", this.userId.length() > 0 ? this.userId : this.username));
407
408         responseStep1 = request.send();
409         final String content = responseStep1.getContentAsString();
410         logger.trace("Xiaomi Login step 1 content response= {}", content);
411         logger.trace("Xiaomi Login step 1 response = {}", responseStep1);
412         try {
413             JsonElement resp = JsonParser.parseString(parseJson(content));
414             CloudLogin1DTO jsonResp = GSON.fromJson(resp, CloudLogin1DTO.class);
415             final String sign = jsonResp.getSign();
416             if (sign != null && !sign.isBlank()) {
417                 logger.trace("Xiaomi Login step 1 sign = {}", sign);
418                 return sign;
419             } else {
420                 logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
421                 return "";
422             }
423         } catch (JsonParseException | IllegalStateException | ClassCastException e) {
424             throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e);
425         }
426     }
427
428     private String loginStep2(String sign) throws MiIoCryptoException, InterruptedException, TimeoutException,
429             ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
430         String passToken;
431         String cUserId;
432
433         logger.trace("Xiaomi Login step 2");
434         String url = "https://account.xiaomi.com/pass/serviceLoginAuth2";
435         Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
436         request.agent(USERAGENT);
437         request.method(HttpMethod.POST);
438         final ContentResponse responseStep2;
439
440         Fields fields = new Fields();
441         fields.put("sid", "xiaomiio");
442         fields.put("hash", Utils.getHex(MiIoCrypto.md5(password.getBytes())));
443         fields.put("callback", "https://sts.api.io.mi.com/sts");
444         fields.put("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue");
445         fields.put("user", username);
446         if (!sign.isEmpty()) {
447             fields.put("_sign", sign);
448         }
449         fields.put("_json", "true");
450
451         request.content(new FormContentProvider(fields));
452         responseStep2 = request.send();
453
454         final String content2 = responseStep2.getContentAsString();
455         logger.trace("Xiaomi login step 2 response = {}", responseStep2);
456         logger.trace("Xiaomi login step 2 content = {}", content2);
457
458         JsonElement resp2 = JsonParser.parseString(parseJson(content2));
459         CloudLoginDTO jsonResp = GSON.fromJson(resp2, CloudLoginDTO.class);
460         if (jsonResp == null) {
461             throw new MiCloudException("Error getting logon details from step 2: " + content2);
462         }
463         ssecurity = jsonResp.getSsecurity();
464         userId = jsonResp.getUserId();
465         cUserId = jsonResp.getcUserId();
466         passToken = jsonResp.getPassToken();
467         String location = jsonResp.getLocation();
468         String code = jsonResp.getCode();
469
470         logger.trace("Xiaomi login ssecurity = {}", ssecurity);
471         logger.trace("Xiaomi login userId = {}", userId);
472         logger.trace("Xiaomi login cUserId = {}", cUserId);
473         logger.trace("Xiaomi login passToken = {}", passToken);
474         logger.trace("Xiaomi login location = {}", location);
475         logger.trace("Xiaomi login code = {}", code);
476         if (0 != jsonResp.getSecurityStatus()) {
477             logger.debug("Xiaomi Cloud Step2 response: {}", parseJson(content2));
478             logger.debug(
479                     "Xiaomi Login code: {} \r\nSecurityStatus: {}\r\nPwd code: {}\r\nLocation logon URL: {}\r\nIn case of login issues check userId/password details are correct.\r\n"
480                             + "If login details are correct, try to logon using browser from the openHAB ip using the browser. Alternatively try to complete logon with above URL.",
481                     jsonResp.getCode(), jsonResp.getSecurityStatus(), jsonResp.getPwd(), jsonResp.getLocation());
482         }
483         if (logger.isTraceEnabled()) {
484             dumpCookies(url, false);
485         }
486         if (!location.isEmpty()) {
487             return location;
488         } else {
489             throw new MiCloudException("Error getting logon location URL. Return code: " + code);
490         }
491     }
492
493     private ContentResponse loginStep3(String location)
494             throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
495         final ContentResponse responseStep3;
496         Request request;
497         logger.trace("Xiaomi Login step 3 @ {}", (new URL(location)).getHost());
498         request = httpClient.newRequest(location).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
499         request.agent(USERAGENT);
500         request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
501         responseStep3 = request.send();
502         logger.trace("Xiaomi login step 3 content = {}", responseStep3.getContentAsString());
503         logger.trace("Xiaomi login step 3 response = {}", responseStep3);
504         if (logger.isTraceEnabled()) {
505             dumpCookies(location, false);
506         }
507         URI uri = URI.create("http://sts.api.io.mi.com");
508         String serviceToken = extractServiceToken(uri);
509         if (!serviceToken.isEmpty()) {
510             this.serviceToken = serviceToken;
511         }
512         return responseStep3;
513     }
514
515     private void dumpCookies(String url, boolean delete) {
516         if (logger.isTraceEnabled()) {
517             try {
518                 URI uri = URI.create(url);
519                 logger.trace("Cookie dump for {}", uri);
520                 CookieStore cs = httpClient.getCookieStore();
521                 if (cs != null) {
522                     List<HttpCookie> cookies = cs.get(uri);
523                     for (HttpCookie cookie : cookies) {
524                         logger.trace("Cookie ({}) : {} --> {}     (path: {}. Removed: {})", cookie.getDomain(),
525                                 cookie.getName(), cookie.getValue(), cookie.getPath(), delete);
526                         if (delete) {
527                             cs.remove(uri, cookie);
528                         }
529                     }
530                 } else {
531                     logger.trace("Could not create cookiestore from {}", url);
532                 }
533             } catch (IllegalArgumentException e) {
534                 logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e);
535             }
536         }
537     }
538
539     private String extractServiceToken(URI uri) {
540         String serviceToken = "";
541         List<HttpCookie> cookies = httpClient.getCookieStore().get(uri);
542         for (HttpCookie cookie : cookies) {
543             logger.trace("Cookie :{} --> {}", cookie.getName(), cookie.getValue());
544             if (cookie.getName().contentEquals("serviceToken")) {
545                 serviceToken = cookie.getValue();
546                 logger.debug("Xiaomi cloud logon successful.");
547                 logger.trace("Xiaomi cloud servicetoken: {}", serviceToken);
548             }
549         }
550         return serviceToken;
551     }
552
553     public boolean hasLoginToken() {
554         return !serviceToken.isEmpty();
555     }
556 }