2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.miio.internal.cloud;
15 import java.io.IOException;
16 import java.net.CookieStore;
17 import java.net.HttpCookie;
18 import java.net.MalformedURLException;
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;
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;
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;
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;
60 * The {@link MiCloudConnector} class is used for connecting to the Xiaomi cloud access
62 * @author Marcel Verpaalen - Initial contribution
65 public class MiCloudConnector {
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();
79 private final String clientId;
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;
89 private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class);
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");
98 clientId = (new Random().ints(97, 122 + 1).limit(6)
99 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
102 void startClient() throws MiCloudException {
103 if (!httpClient.isStarted()) {
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);
118 public void stopClient() {
120 this.httpClient.stop();
121 } catch (Exception e) {
122 logger.debug("Error stopping httpclient :{}", e.getMessage(), e);
126 private boolean checkCredentials() {
127 if (username.trim().isEmpty() || password.trim().isEmpty()) {
128 logger.info("Xiaomi Cloud: username or password missing.");
134 private String getApiUrl(String country) {
135 return "https://" + (country.trim().equalsIgnoreCase("cn") ? "" : country.trim().toLowerCase() + ".")
136 + "api.io.mi.com/app";
139 public String getClientId() {
143 String parseJson(String data) {
144 if (data.contains("&&&START&&&")) {
145 return data.replace("&&&START&&&", "");
147 return UNEXPECTED.concat(data);
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 = "";
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();
166 return jo.get("url").getAsString();
168 errorMsg = "Could not get url";
171 errorMsg = "Could not get result";
174 errorMsg = "Received message is invalid JSON";
176 } catch (ClassCastException | IllegalStateException e) {
177 errorMsg = "Received message could not be parsed";
179 logger.debug("{}: {}", errorMsg, mapResponse);
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);
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);
193 if (country.length() > 3 || country.length() < 2) {
194 logger.debug("Country ('{}') incorrect or missing. Command not send: {}", device, command);
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);
204 final String response = request("/home/rpc/" + id, country, command);
205 logger.debug("response: {}", response);
209 public List<CloudDeviceDTO> getDevices(String country) {
210 final String response = getDeviceString(country);
211 List<CloudDeviceDTO> devicesList = new ArrayList<>();
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);
219 devicesList.addAll(cdl.getCloudDevices());
220 for (CloudDeviceDTO device : devicesList) {
221 device.setServer(country);
222 logger.debug("Xiaomi cloud info: {}", device);
226 logger.debug("Response missing result: '{}'", response);
229 logger.debug("Response is not a json object: '{}'", response);
231 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
232 loginFailedCounter++;
233 logger.info("Error while parsing devices: {}", e.getMessage());
238 public String getDeviceString(String country) {
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);
247 } catch (MiCloudException e) {
248 logger.info("{}", e.getMessage());
249 loginFailedCounter++;
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);
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);
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");
272 loginFailedCounterCheck();
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"));
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());
294 String method = "POST";
295 request.method(method);
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);
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));
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 = "";
314 return response.getContentAsString();
315 } catch (HttpResponseException e) {
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++;
329 private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
330 HttpCookie cookie = new HttpCookie(name, value);
331 cookie.setDomain("." + domain);
333 cookieStore.add(URI.create("https://" + domain), cookie);
336 public synchronized boolean login() {
337 if (!checkCredentials()) {
340 if (!userId.isEmpty() && !serviceToken.isEmpty()) {
343 logger.debug("Xiaomi cloud login with userid {}", username);
345 if (loginRequest()) {
346 loginFailedCounter = 0;
348 loginFailedCounter++;
349 logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
351 } catch (MiCloudException e) {
352 logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
353 loginFailedCounter++;
355 loginFailedCounterCheck();
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);
367 loginFailedCounter = 0;
371 protected boolean loginRequest() throws MiCloudException {
374 String sign = loginStep1();
376 if (!sign.startsWith("http")) {
377 location = loginStep2(sign);
379 location = sign; // seems we already have login location
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:
388 logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
389 responseStep3.getReason(), responseStep3.getContentAsString());
390 throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
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);
401 private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
402 final ContentResponse responseStep1;
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));
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);
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);
422 logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
425 } catch (JsonParseException | IllegalStateException | ClassCastException e) {
426 throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e);
430 private String loginStep2(String sign) throws MiIoCryptoException, InterruptedException, TimeoutException,
431 ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
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;
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);
451 fields.put("_json", "true");
453 request.content(new FormContentProvider(fields));
454 responseStep2 = request.send();
456 final String content2 = responseStep2.getContentAsString();
457 logger.trace("Xiaomi login step 2 response = {}", responseStep2);
458 logger.trace("Xiaomi login step 2 content = {}", content2);
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);
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();
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);
481 if (!location.isEmpty()) {
484 throw new MiCloudException("Error getting logon location URL. Return code: " + code);
488 private ContentResponse loginStep3(String location)
489 throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
490 final ContentResponse responseStep3;
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);
502 URI uri = URI.create("http://sts.api.io.mi.com");
503 String serviceToken = extractServiceToken(uri);
504 if (!serviceToken.isEmpty()) {
505 this.serviceToken = serviceToken;
507 return responseStep3;
510 private void dumpCookies(String url, boolean delete) {
511 if (logger.isTraceEnabled()) {
513 URI uri = URI.create(url);
514 logger.trace("Cookie dump for {}", uri);
515 CookieStore cs = httpClient.getCookieStore();
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);
522 cs.remove(uri, cookie);
526 logger.trace("Could not create cookiestore from {}", url);
528 } catch (IllegalArgumentException e) {
529 logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e);
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);
548 public boolean hasLoginToken() {
549 return !serviceToken.isEmpty();