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 List<CloudDeviceDTO> getDevices(String country) {
205 final String response = getDeviceString(country);
206 List<CloudDeviceDTO> devicesList = new ArrayList<>();
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);
214 devicesList.addAll(cdl.getCloudDevices());
215 for (CloudDeviceDTO device : devicesList) {
216 device.setServer(country);
217 logger.debug("Xiaomi cloud info: {}", device);
221 logger.debug("Response missing result: '{}'", response);
224 logger.debug("Response is not a json object: '{}'", response);
226 } catch (JsonSyntaxException | IllegalStateException | ClassCastException e) {
227 loginFailedCounter++;
228 logger.info("Error while parsing devices: {}", e.getMessage());
233 public String getDeviceString(String country) {
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);
242 } catch (MiCloudException e) {
243 logger.info("{}", e.getMessage());
244 loginFailedCounter++;
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);
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);
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");
267 loginFailedCounterCheck();
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"));
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());
289 String method = "POST";
290 request.method(method);
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);
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));
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 = "";
309 return response.getContentAsString();
310 } catch (HttpResponseException e) {
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++;
324 private void addCookie(CookieStore cookieStore, String name, String value, String domain) {
325 HttpCookie cookie = new HttpCookie(name, value);
326 cookie.setDomain("." + domain);
328 cookieStore.add(URI.create("https://" + domain), cookie);
331 public synchronized boolean login() {
332 if (!checkCredentials()) {
335 if (!userId.isEmpty() && !serviceToken.isEmpty()) {
338 logger.debug("Xiaomi cloud login with userid {}", username);
340 if (loginRequest()) {
341 loginFailedCounter = 0;
343 loginFailedCounter++;
344 logger.debug("Xiaomi cloud login attempt {}", loginFailedCounter);
346 } catch (MiCloudException e) {
347 logger.info("Error logging on to Xiaomi cloud ({}): {}", loginFailedCounter, e.getMessage());
348 loginFailedCounter++;
350 loginFailedCounterCheck();
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);
362 loginFailedCounter = 0;
366 protected boolean loginRequest() throws MiCloudException {
369 String sign = loginStep1();
371 if (!sign.startsWith("http")) {
372 location = loginStep2(sign);
374 location = sign; // seems we already have login location
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:
383 logger.trace("request returned status '{}', reason: {}, content = {}", responseStep3.getStatus(),
384 responseStep3.getReason(), responseStep3.getContentAsString());
385 throw new MiCloudException(responseStep3.getStatus() + responseStep3.getReason());
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);
396 private String loginStep1() throws InterruptedException, TimeoutException, ExecutionException, MiCloudException {
397 final ContentResponse responseStep1;
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));
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);
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);
418 logger.debug("Xiaomi Login _sign missing. Maybe still has login cookie.");
421 } catch (JsonParseException | IllegalStateException | ClassCastException e) {
422 throw new MiCloudException("Error getting logon sign. Cannot parse response: " + e.getMessage(), e);
426 private String loginStep2(String sign) throws MiIoCryptoException, InterruptedException, TimeoutException,
427 ExecutionException, MiCloudException, JsonSyntaxException, JsonParseException {
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;
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);
447 fields.put("_json", "true");
449 request.content(new FormContentProvider(fields));
450 responseStep2 = request.send();
452 final String content2 = responseStep2.getContentAsString();
453 logger.trace("Xiaomi login step 2 response = {}", responseStep2);
454 logger.trace("Xiaomi login step 2 content = {}", content2);
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);
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();
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));
478 Xiaomi Login 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.\
485 jsonResp.getCode(), jsonResp.getSecurityStatus(), jsonResp.getPwd(), jsonResp.getLocation());
487 if (logger.isTraceEnabled()) {
488 dumpCookies(url, false);
490 if (!location.isEmpty()) {
493 throw new MiCloudException("Error getting logon location URL. Return code: " + code);
497 private ContentResponse loginStep3(String location)
498 throws MalformedURLException, InterruptedException, TimeoutException, ExecutionException {
499 final ContentResponse responseStep3;
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);
511 URI uri = URI.create("http://sts.api.io.mi.com");
512 String serviceToken = extractServiceToken(uri);
513 if (!serviceToken.isEmpty()) {
514 this.serviceToken = serviceToken;
516 return responseStep3;
519 private void dumpCookies(String url, boolean delete) {
520 if (logger.isTraceEnabled()) {
522 URI uri = URI.create(url);
523 logger.trace("Cookie dump for {}", uri);
524 CookieStore cs = httpClient.getCookieStore();
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);
531 cs.remove(uri, cookie);
535 logger.trace("Could not create cookiestore from {}", url);
537 } catch (IllegalArgumentException e) {
538 logger.trace("Error dumping cookies from {}: {}", url, e.getMessage(), e);
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);
557 public boolean hasLoginToken() {
558 return !serviceToken.isEmpty();