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