2 * Copyright (c) 2010-2023 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();
78 private final String clientId;
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;
88 private final Logger logger = LoggerFactory.getLogger(MiCloudConnector.class);
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");
97 clientId = (new Random().ints(97, 122 + 1).limit(6)
98 .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append).toString());
101 void startClient() throws MiCloudException {
102 if (!httpClient.isStarted()) {
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);
117 public void stopClient() {
119 this.httpClient.stop();
120 } catch (Exception e) {
121 logger.debug("Error stopping httpclient :{}", e.getMessage(), e);
125 private boolean checkCredentials() {
126 if (username.trim().isEmpty() || password.trim().isEmpty()) {
127 logger.info("Xiaomi Cloud: username or password missing.");
133 private String getApiUrl(String country) {
134 return "https://" + ("cn".equalsIgnoreCase(country.trim()) ? "" : country.trim().toLowerCase() + ".")
135 + "api.io.mi.com/app";
138 public String getClientId() {
142 String parseJson(String data) {
143 if (data.contains("&&&START&&&")) {
144 return data.replace("&&&START&&&", "");
146 return UNEXPECTED.concat(data);
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 = "";
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();
165 return jo.get("url").getAsString();
167 errorMsg = "Could not get url";
170 errorMsg = "Could not get result";
173 errorMsg = "Received message is invalid JSON";
175 } catch (ClassCastException | IllegalStateException e) {
176 errorMsg = "Received message could not be parsed";
178 logger.debug("{}: {}", errorMsg, mapResponse);
182 public String getDeviceStatus(String device, String country) throws MiCloudException {
183 return request("/home/device_list", country, "{\"dids\":[\"" + device + "\"]}");
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);
190 if (country.length() > 3 || country.length() < 2) {
191 logger.debug("Country ('{}') incorrect or missing. Command not send: {}", device, command);
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);
201 return request("/home/rpc/" + id, country, command);
204 public JsonObject getHomeList(String country) {
205 String response = "";
207 response = request("/homeroom/gethome", country,
208 "{\"fg\":false,\"fetch_share\":true,\"fetch_share_dev\":true,\"limit\":300,\"app_ver\":7,\"fetch_cariot\":true}");
209 logger.trace("gethome response: {}", response);
210 final JsonElement resp = JsonParser.parseString(response);
211 if (resp.isJsonObject() && resp.getAsJsonObject().has("result")) {
212 return resp.getAsJsonObject().get("result").getAsJsonObject();
214 } catch (JsonParseException e) {
215 logger.info("{} error while parsing rooms: '{}'", e.getMessage(), response);
216 } catch (MiCloudException e) {
217 logger.info("{}", e.getMessage());
218 loginFailedCounter++;
220 return new JsonObject();
223 public List<CloudDeviceDTO> getDevices(String country) {
224 final String response = getDeviceString(country);
225 List<CloudDeviceDTO> devicesList = new ArrayList<>();
227 final JsonElement resp = JsonParser.parseString(response);
228 if (resp.isJsonObject()) {
229 final JsonObject jor = resp.getAsJsonObject();
230 if (jor.has("result")) {
231 CloudDeviceListDTO cdl = GSON.fromJson(jor.get("result"), CloudDeviceListDTO.class);
233 devicesList.addAll(cdl.getCloudDevices());
234 for (CloudDeviceDTO device : devicesList) {
235 device.setServer(country);
236 logger.debug("Xiaomi cloud info: {}", device);
240 logger.debug("Response missing result: '{}'", response);
243 logger.debug("Response is not a json object: '{}'", response);
245 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
246 loginFailedCounter++;
247 logger.info("Error while parsing devices: {}", e.getMessage());
252 public String getDeviceString(String country) {
255 resp = request("/home/device_list_page", country, "{\"getVirtualModel\":true,\"getHuamiDevices\":1}");
256 logger.trace("Get devices response: {}", resp);
257 if (resp.length() > 2) {
258 CloudUtil.saveDeviceInfoFile(resp, country, logger);
261 } catch (MiCloudException e) {
262 logger.info("{}", e.getMessage());
263 loginFailedCounter++;
268 public String request(String urlPart, String country, String params) throws MiCloudException {
269 Map<String, String> map = new HashMap<String, String>();
270 map.put("data", params);
271 return request(urlPart, country, map);
274 public String request(String urlPart, String country, Map<String, String> params) throws MiCloudException {
275 String url = urlPart.trim();
276 url = getApiUrl(country) + (url.startsWith("/app") ? url.substring(4) : url);
277 String response = request(url, params);
278 logger.debug("Request to '{}' server '{}'. Response: '{}'", country, urlPart, response);
282 public String request(String url, Map<String, String> params) throws MiCloudException {
283 if (this.serviceToken.isEmpty() || this.userId.isEmpty()) {
284 throw new MiCloudException("Cannot execute request. service token or userId missing");
286 loginFailedCounterCheck();
288 logger.debug("Send request to {} with data '{}'", url, params.get("data"));
289 Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
290 request.agent(USERAGENT);
291 request.header("x-xiaomi-protocal-flag-cli", "PROTOCAL-HTTP2");
292 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
293 request.cookie(new HttpCookie("userId", this.userId));
294 request.cookie(new HttpCookie("yetAnotherServiceToken", this.serviceToken));
295 request.cookie(new HttpCookie("serviceToken", this.serviceToken));
296 request.cookie(new HttpCookie("locale", locale.toString()));
297 request.cookie(new HttpCookie("timezone", ZonedDateTime.now().format(FORMATTER)));
298 request.cookie(new HttpCookie("is_daylight", TZ.inDaylightTime(new Date()) ? "1" : "0"));
299 request.cookie(new HttpCookie("dst_offset", Integer.toString(TZ.getDSTSavings())));
300 request.cookie(new HttpCookie("channel", "MI_APP_STORE"));
302 if (logger.isTraceEnabled()) {
303 for (HttpCookie cookie : request.getCookies()) {
304 logger.trace("Cookie set for request ({}) : {} --> {} (path: {})", cookie.getDomain(),
305 cookie.getName(), cookie.getValue(), cookie.getPath());
308 String method = "POST";
309 request.method(method);
312 String nonce = CloudUtil.generateNonce(System.currentTimeMillis());
313 String signedNonce = CloudUtil.signedNonce(ssecurity, nonce);
314 String signature = CloudUtil.generateSignature(url.replace("/app", ""), signedNonce, nonce, params);
316 Fields fields = new Fields();
317 fields.put("signature", signature);
318 fields.put("_nonce", nonce);
319 fields.put("data", params.get("data"));
320 request.content(new FormContentProvider(fields));
322 logger.trace("fieldcontent: {}", fields.toString());
323 final ContentResponse response = request.send();
324 if (response.getStatus() >= HttpStatus.BAD_REQUEST_400
325 && response.getStatus() < HttpStatus.INTERNAL_SERVER_ERROR_500) {
326 this.serviceToken = "";
328 return response.getContentAsString();
329 } catch (HttpResponseException e) {
331 logger.debug("Error while executing request to {} :{}", url, e.getMessage());
332 loginFailedCounter++;
333 } catch (InterruptedException | TimeoutException | ExecutionException | IOException e) {
334 logger.debug("Error while executing request to {} :{}", url, e.getMessage());
335 loginFailedCounter++;
336 } catch (MiIoCryptoException e) {
337 logger.debug("Error while decrypting response of request to {} :{}", url, e.getMessage(), e);
338 loginFailedCounter++;
343 private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
344 HttpCookie cookie = new HttpCookie(name, value);
345 cookie.setDomain("." + domain);
347 cookieStore.add(URI.create("https://" + domain), cookie);
350 public synchronized boolean login() {
351 if (!checkCredentials()) {
354 if (!userId.isEmpty() && !serviceToken.isEmpty()) {
357 logger.debug("Xiaomi cloud login with userid {}", username);
359 if (loginRequest()) {
360 loginFailedCounter = 0;
362 loginFailedCounter++;
363 logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
365 } catch (MiCloudException e) {
366 logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
367 loginFailedCounter++;
369 loginFailedCounterCheck();
375 void loginFailedCounterCheck() {
376 if (loginFailedCounter > 10) {
377 logger.info("Repeated errors logging on to Xiaomi cloud. Cleaning stored cookies");
378 dumpCookies(".xiaomi.com", true);
379 dumpCookies(".mi.com", true);
381 loginFailedCounter = 0;
385 protected boolean loginRequest() throws MiCloudException {
388 String sign = loginStep1();
390 if (!sign.startsWith("http")) {
391 location = loginStep2(sign);
393 location = sign; // seems we already have login location
395 final ContentResponse responseStep3 = loginStep3(location);
396 switch (responseStep3.getStatus()) {
397 case HttpStatus.FORBIDDEN_403:
398 throw new MiCloudException("Access denied. Did you set the correct api-key and/or username?");
399 case HttpStatus.OK_200:
402 logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
403 responseStep3.getReason(), responseStep3.getContentAsString());
404 throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
406 } catch (InterruptedException | TimeoutException | ExecutionException e) {
407 throw new MiCloudException("Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
408 } catch (MiIoCryptoException e) {
409 throw new MiCloudException("Error decrypting. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
410 } catch (MalformedURLException | JsonParseException e) {
411 throw new MiCloudException("Error getting logon URL. Cannot logon to Xiaomi cloud: " + e.getMessage(), e);
415 private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
416 final ContentResponse responseStep1;
418 logger.trace("Xiaomi Login step 1");
419 String url = "https://account.xiaomi.com/pass/serviceLogin?sid=xiaomiio&_json=true";
420 Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
421 request.agent(USERAGENT);
422 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
423 request.cookie(new HttpCookie("userId", this.userId.length() > 0 ? this.userId : this.username));
425 responseStep1 = request.send();
426 final String content = responseStep1.getContentAsString();
427 logger.trace("Xiaomi Login step 1 content response= {}", content);
428 logger.trace("Xiaomi Login step 1 response = {}", responseStep1);
430 JsonElement resp = JsonParser.parseString(parseJson(content));
431 CloudLogin1DTO jsonResp = GSON.fromJson(resp, CloudLogin1DTO.class);
432 final String sign = jsonResp != null ? jsonResp.getSign() : null;
433 if (sign != null && !sign.isBlank()) {
434 logger.trace("Xiaomi Login step 1 sign = {}", sign);
437 logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
440 } catch (JsonParseException | IllegalStateException | ClassCastException e) {
441 throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e);
445 private String loginStep2(String sign) throws MiIoCryptoException, InterruptedException, TimeoutException,
446 ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
450 logger.trace("Xiaomi Login step 2");
451 String url = "https://account.xiaomi.com/pass/serviceLoginAuth2";
452 Request request = httpClient.newRequest(url).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
453 request.agent(USERAGENT);
454 request.method(HttpMethod.POST);
455 final ContentResponse responseStep2;
457 Fields fields = new Fields();
458 fields.put("sid", "xiaomiio");
459 fields.put("hash", Utils.getHex(MiIoCrypto.md5(password.getBytes())));
460 fields.put("callback", "https://sts.api.io.mi.com/sts");
461 fields.put("qs", "%3Fsid%3Dxiaomiio%26_json%3Dtrue");
462 fields.put("user", username);
463 if (!sign.isEmpty()) {
464 fields.put("_sign", sign);
466 fields.put("_json", "true");
468 request.content(new FormContentProvider(fields));
469 responseStep2 = request.send();
471 final String content2 = responseStep2.getContentAsString();
472 logger.trace("Xiaomi login step 2 response = {}", responseStep2);
473 logger.trace("Xiaomi login step 2 content = {}", content2);
475 JsonElement resp2 = JsonParser.parseString(parseJson(content2));
476 CloudLoginDTO jsonResp = GSON.fromJson(resp2, CloudLoginDTO.class);
477 if (jsonResp == null) {
478 throw new MiCloudException("Error getting logon details from step 2: " + content2);
480 ssecurity = jsonResp.getSsecurity();
481 userId = jsonResp.getUserId();
482 cUserId = jsonResp.getcUserId();
483 passToken = jsonResp.getPassToken();
484 String location = jsonResp.getLocation();
485 String code = jsonResp.getCode();
487 logger.trace("Xiaomi login ssecurity = {}", ssecurity);
488 logger.trace("Xiaomi login userId = {}", userId);
489 logger.trace("Xiaomi login cUserId = {}", cUserId);
490 logger.trace("Xiaomi login passToken = {}", passToken);
491 logger.trace("Xiaomi login location = {}", location);
492 logger.trace("Xiaomi login code = {}", code);
493 if (0 != jsonResp.getSecurityStatus()) {
494 logger.debug("Xiaomi Cloud Step2 response: {}", parseJson(content2));
497 Xiaomi Login code: {}
500 Location logon URL: {}
501 In case of login issues check userId/password details are correct.
502 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.\
504 jsonResp.getCode(), jsonResp.getSecurityStatus(), jsonResp.getPwd(), jsonResp.getLocation());
506 if (logger.isTraceEnabled()) {
507 dumpCookies(url, false);
509 if (!location.isEmpty()) {
512 throw new MiCloudException("Error getting logon location URL. Return code: " + code);
516 private ContentResponse loginStep3(String location)
517 throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
518 final ContentResponse responseStep3;
520 logger.trace("Xiaomi Login step 3 @ {}", (new URL(location)).getHost());
521 request = httpClient.newRequest(location).timeout(REQUEST_TIMEOUT_SECONDS, TimeUnit.SECONDS);
522 request.agent(USERAGENT);
523 request.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded");
524 responseStep3 = request.send();
525 logger.trace("Xiaomi login step 3 content = {}", responseStep3.getContentAsString());
526 logger.trace("Xiaomi login step 3 response = {}", responseStep3);
527 if (logger.isTraceEnabled()) {
528 dumpCookies(location, false);
530 URI uri = URI.create("http://sts.api.io.mi.com");
531 String serviceToken = extractServiceToken(uri);
532 if (!serviceToken.isEmpty()) {
533 this.serviceToken = serviceToken;
535 return responseStep3;
538 private void dumpCookies(String url, boolean delete) {
539 if (logger.isTraceEnabled()) {
541 URI uri = URI.create(url);
542 logger.trace("Cookie dump for {}", uri);
543 CookieStore cs = httpClient.getCookieStore();
545 List<HttpCookie> cookies = cs.get(uri);
546 for (HttpCookie cookie : cookies) {
547 logger.trace("Cookie ({}) : {} --> {} (path: {}. Removed: {})", cookie.getDomain(),
548 cookie.getName(), cookie.getValue(), cookie.getPath(), delete);
550 cs.remove(uri, cookie);
554 logger.trace("Could not create cookiestore from {}", url);
556 } catch (IllegalArgumentException e) {
557 logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e);
562 private String extractServiceToken(URI uri) {
563 String serviceToken = "";
564 List<HttpCookie> cookies = httpClient.getCookieStore().get(uri);
565 for (HttpCookie cookie : cookies) {
566 logger.trace("Cookie :{} --> {}", cookie.getName(), cookie.getValue());
567 if (cookie.getName().contentEquals("serviceToken")) {
568 serviceToken = cookie.getValue();
569 logger.debug("Xiaomi cloud logon successful.");
570 logger.trace("Xiaomi cloud servicetoken: {}", serviceToken);
576 public boolean hasLoginToken() {
577 return !serviceToken.isEmpty();