Skip to content

Commit 67daa7e

Browse files
authored
[ecovacs] Add support for new API for fetching cleaning logs (openhab#16524)
The existing cleaning logs API is only populated for devices older than the T9/N9 generation; all newer devices use a new API. Since the new API isn't populated for older devices, select the correct API depending on device type. Signed-off-by: Danny Baumann <[email protected]>
1 parent 3d65b30 commit 67daa7e

15 files changed

+279
-109
lines changed

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsBindingConstants.java

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class EcovacsBindingConstants {
3737
public static final String CLIENT_SECRET = "6c319b2a5cd3e66e39159c2e28f2fce9";
3838
public static final String AUTH_CLIENT_KEY = "1520391491841";
3939
public static final String AUTH_CLIENT_SECRET = "77ef58ce3afbe337da74aa8c5ab963a9";
40+
public static final String APP_KEY = "2ea31cf06e6711eaa0aff7b9558a534e";
4041

4142
// List of all Thing Type UIDs
4243
public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "ecovacsapi");

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiConfiguration.java

+24-5
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
package org.openhab.binding.ecovacs.internal.api;
1414

1515
import org.eclipse.jdt.annotation.NonNullByDefault;
16-
import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
16+
import org.openhab.binding.ecovacs.internal.api.util.HashUtil;
1717

1818
/**
1919
* @author Johannes Ptaszyk - Initial contribution
@@ -30,10 +30,12 @@ public final class EcovacsApiConfiguration {
3030
private final String clientSecret;
3131
private final String authClientKey;
3232
private final String authClientSecret;
33+
private final String appKey;
3334

3435
public EcovacsApiConfiguration(String deviceId, String username, String password, String continent, String country,
35-
String language, String clientKey, String clientSecret, String authClientKey, String authClientSecret) {
36-
this.deviceId = MD5Util.getMD5Hash(deviceId);
36+
String language, String clientKey, String clientSecret, String authClientKey, String authClientSecret,
37+
String appKey) {
38+
this.deviceId = HashUtil.getMD5Hash(deviceId);
3739
this.username = username;
3840
this.password = password;
3941
this.continent = continent;
@@ -43,6 +45,7 @@ public EcovacsApiConfiguration(String deviceId, String username, String password
4345
this.clientSecret = clientSecret;
4446
this.authClientKey = authClientKey;
4547
this.authClientSecret = authClientSecret;
48+
this.appKey = appKey;
4649
}
4750

4851
public String getDeviceId() {
@@ -90,7 +93,7 @@ public String getRealm() {
9093
return "ecouser.net";
9194
}
9295

93-
public String getPortalAUthRequestWith() {
96+
public String getPortalAuthRequestWith() {
9497
return "users";
9598
}
9699

@@ -110,12 +113,28 @@ public String getChannel() {
110113
return "google_play";
111114
}
112115

116+
public String getAppId() {
117+
return "ecovacs";
118+
}
119+
120+
public String getAppPlatform() {
121+
return "android";
122+
}
123+
113124
public String getAppCode() {
114125
return "global_e";
115126
}
116127

117128
public String getAppVersion() {
118-
return "1.6.3";
129+
return "2.3.7";
130+
}
131+
132+
public String getAppKey() {
133+
return appKey;
134+
}
135+
136+
public String getAppUserAgent() {
137+
return "EcovacsHome/2.3.7 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)";
119138
}
120139

121140
public String getDeviceType() {

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsDevice.java

+2
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,6 @@ void connect(EventListener listener, ScheduledExecutorService scheduler)
5959
<T> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException;
6060

6161
List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException;
62+
63+
Optional<byte[]> downloadCleanMapImage(CleanLogRecord record) throws EcovacsApiException, InterruptedException;
6264
}

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiImpl.java

+56-7
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,16 @@
5959
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.AbstractPortalIotCommandResponse;
6060
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
6161
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.IotProduct;
62+
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogRecord;
6263
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogsResponse;
64+
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanResultsResponse;
6365
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalDeviceResponse;
6466
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandJsonResponse;
6567
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
6668
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotProductResponse;
6769
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
6870
import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
69-
import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
71+
import org.openhab.binding.ecovacs.internal.api.util.HashUtil;
7072
import org.openhab.core.OpenHAB;
7173
import org.slf4j.Logger;
7274
import org.slf4j.LoggerFactory;
@@ -116,8 +118,8 @@ PortalLoginResponse getLoginData() {
116118
private AccessData login() throws EcovacsApiException, InterruptedException {
117119
HashMap<String, String> loginParameters = new HashMap<>();
118120
loginParameters.put("account", configuration.getUsername());
119-
loginParameters.put("password", MD5Util.getMD5Hash(configuration.getPassword()));
120-
loginParameters.put("requestId", MD5Util.getMD5Hash(String.valueOf(System.currentTimeMillis())));
121+
loginParameters.put("password", HashUtil.getMD5Hash(configuration.getPassword()));
122+
loginParameters.put("requestId", HashUtil.getMD5Hash(String.valueOf(System.currentTimeMillis())));
121123
loginParameters.put("authTimeZone", configuration.getTimeZone());
122124
loginParameters.put("country", configuration.getCountry());
123125
loginParameters.put("lang", configuration.getLanguage());
@@ -310,8 +312,7 @@ public <T> T sendIotCommand(Device device, DeviceDescription desc, IotDeviceComm
310312
}
311313
}
312314

313-
public List<PortalCleanLogsResponse.LogRecord> fetchCleanLogs(Device device)
314-
throws EcovacsApiException, InterruptedException {
315+
public List<PortalCleanLogRecord> fetchCleanLogs(Device device) throws EcovacsApiException, InterruptedException {
315316
PortalCleanLogsRequest data = new PortalCleanLogsRequest(createAuthData(), device.getDid(),
316317
device.getResource());
317318
String url = EcovacsApiUrlFactory.getPortalLogUrl(configuration);
@@ -324,12 +325,39 @@ public List<PortalCleanLogsResponse.LogRecord> fetchCleanLogs(Device device)
324325
return responseObj.records;
325326
}
326327

328+
public List<PortalCleanLogRecord> fetchCleanResultsLog(Device device)
329+
throws EcovacsApiException, InterruptedException {
330+
String url = EcovacsApiUrlFactory.getPortalCleanResultsLogUrl(configuration);
331+
Request request = createSignedAppRequest(url).param("auth", gson.toJson(createAuthData())) //
332+
.param("channel", configuration.getChannel()) //
333+
.param("did", device.getDid()) //
334+
.param("defaultLang", "EN") //
335+
.param("logType", "clean") //
336+
.param("res", device.getResource()) //
337+
.param("size", "20") //
338+
.param("version", "v2");
339+
340+
ContentResponse response = executeRequest(request);
341+
PortalCleanResultsResponse responseObj = handleResponse(response, PortalCleanResultsResponse.class);
342+
if (!responseObj.wasSuccessful()) {
343+
throw new EcovacsApiException("Fetching clean results failed");
344+
}
345+
logger.trace("{}: Fetching cleaning results yields {} records", device.getName(), responseObj.records.size());
346+
return responseObj.records;
347+
}
348+
349+
public byte[] downloadCleanMapImage(String url, boolean useSigning)
350+
throws EcovacsApiException, InterruptedException {
351+
Request request = useSigning ? createSignedAppRequest(url) : httpClient.newRequest(url).method(HttpMethod.GET);
352+
return executeRequest(request).getContent();
353+
}
354+
327355
private PortalAuthRequestParameter createAuthData() {
328356
PortalLoginResponse loginData = this.loginData;
329357
if (loginData == null) {
330358
throw new IllegalStateException("Not logged in");
331359
}
332-
return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
360+
return new PortalAuthRequestParameter(configuration.getPortalAuthRequestWith(), loginData.getUserId(),
333361
configuration.getRealm(), loginData.getToken(), configuration.getResource());
334362
}
335363

@@ -371,14 +399,35 @@ private Request createAuthRequest(String url, String clientKey, String clientSec
371399
signOnText.append(clientSecret);
372400

373401
signedRequestParameters.put("authAppkey", clientKey);
374-
signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
402+
signedRequestParameters.put("authSign", HashUtil.getMD5Hash(signOnText.toString()));
375403

376404
Request request = httpClient.newRequest(url).method(HttpMethod.GET);
377405
signedRequestParameters.forEach(request::param);
378406

379407
return request;
380408
}
381409

410+
private Request createSignedAppRequest(String url) {
411+
String timestamp = Long.toString(System.currentTimeMillis());
412+
String signContent = configuration.getAppId() + configuration.getAppKey() + timestamp;
413+
PortalLoginResponse loginData = this.loginData;
414+
if (loginData == null) {
415+
throw new IllegalStateException("Not logged in");
416+
}
417+
return httpClient.newRequest(url).method(HttpMethod.GET)
418+
.header("Authorization", "Bearer " + loginData.getToken()) //
419+
.header("token", loginData.getToken()) //
420+
.header("appid", configuration.getAppId()) //
421+
.header("plat", configuration.getAppPlatform()) //
422+
.header("userid", loginData.getUserId()) //
423+
.header("user-agent", configuration.getAppUserAgent()) //
424+
.header("v", configuration.getAppVersion()) //
425+
.header("country", configuration.getCountry()) //
426+
.header("sign", HashUtil.getSHA256Hash(signContent)) //
427+
.header("signType", "sha256") //
428+
.param("et1", timestamp);
429+
}
430+
382431
private Request createJsonRequest(String url, Object data) {
383432
return httpClient.newRequest(url).method(HttpMethod.POST).header(HttpHeader.CONTENT_TYPE, "application/json")
384433
.content(new StringContentProvider(gson.toJson(data)));

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiUrlFactory.java

+10-5
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ private EcovacsApiUrlFactory() {
2727

2828
private static final String MAIN_URL_LOGIN_PATH = "/user/login";
2929

30-
private static final String PORTAL_USERS_PATH = "/users/user.do";
31-
private static final String PORTAL_IOT_PRODUCT_PATH = "/pim/product/getProductIotMap";
32-
private static final String PORTAL_IOT_DEVMANAGER_PATH = "/iot/devmanager.do";
33-
private static final String PORTAL_LOG_PATH = "/lg/log.do";
30+
private static final String PORTAL_USERS_PATH = "/api/users/user.do";
31+
private static final String PORTAL_IOT_PRODUCT_PATH = "/api/pim/product/getProductIotMap";
32+
private static final String PORTAL_IOT_DEVMANAGER_PATH = "/api/iot/devmanager.do";
33+
private static final String PORTAL_LOG_PATH = "/api/lg/log.do";
34+
private static final String PORTAL_CLEAN_RESULTS_PATH = "/app/dln/api/log/clean_result/list";
3435

3536
public static String getLoginUrl(EcovacsApiConfiguration config) {
3637
return getMainUrl(config) + MAIN_URL_LOGIN_PATH;
@@ -57,9 +58,13 @@ public static String getPortalLogUrl(EcovacsApiConfiguration config) {
5758
return getPortalUrl(config) + PORTAL_LOG_PATH;
5859
}
5960

61+
public static String getPortalCleanResultsLogUrl(EcovacsApiConfiguration config) {
62+
return getPortalUrl(config) + PORTAL_CLEAN_RESULTS_PATH;
63+
}
64+
6065
private static String getPortalUrl(EcovacsApiConfiguration config) {
6166
String continentSuffix = "cn".equalsIgnoreCase(config.getCountry()) ? "" : "-" + config.getContinent();
62-
return String.format("https://portal%1$s.ecouser.net/api", continentSuffix);
67+
return String.format("https://portal%1$s.ecouser.net", continentSuffix);
6368
}
6469

6570
private static String getMainUrl(EcovacsApiConfiguration config) {

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsIotMqDevice.java

+16-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.openhab.binding.ecovacs.internal.api.commands.GetFirmwareVersionCommand;
3535
import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
3636
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
37+
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogRecord;
3738
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
3839
import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
3940
import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
@@ -103,12 +104,25 @@ public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, Interrupt
103104
if (desc.protoVersion == ProtocolVersion.XML) {
104105
logEntries = sendCommand(new GetCleanLogsCommand()).stream();
105106
} else {
106-
logEntries = api.fetchCleanLogs(device).stream().map(record -> new CleanLogRecord(record.timestamp,
107-
record.duration, record.area, Optional.ofNullable(record.imageUrl), record.type));
107+
List<PortalCleanLogRecord> log = hasCapability(DeviceCapability.USES_CLEAN_RESULTS_LOG_API)
108+
? api.fetchCleanResultsLog(device)
109+
: api.fetchCleanLogs(device);
110+
logEntries = log.stream().map(record -> new CleanLogRecord(record.timestamp, record.duration, record.area,
111+
Optional.ofNullable(record.imageUrl), record.type));
108112
}
109113
return logEntries.sorted((lhs, rhs) -> rhs.timestamp.compareTo(lhs.timestamp)).collect(Collectors.toList());
110114
}
111115

116+
@Override
117+
public Optional<byte[]> downloadCleanMapImage(CleanLogRecord record)
118+
throws EcovacsApiException, InterruptedException {
119+
if (record.mapImageUrl.isEmpty()) {
120+
return Optional.empty();
121+
}
122+
boolean needsSigning = hasCapability(DeviceCapability.USES_CLEAN_RESULTS_LOG_API);
123+
return Optional.of(api.downloadCleanMapImage(record.mapImageUrl.get(), needsSigning));
124+
}
125+
112126
@Override
113127
public void connect(final EventListener listener, ScheduledExecutorService scheduler)
114128
throws EcovacsApiException, InterruptedException {

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsXmppDevice.java

+6
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, Interrupt
153153
return sendCommand(new GetCleanLogsCommand());
154154
}
155155

156+
@Override
157+
public Optional<byte[]> downloadCleanMapImage(CleanLogRecord record)
158+
throws EcovacsApiException, InterruptedException {
159+
return Optional.empty();
160+
}
161+
156162
@Override
157163
public void connect(final EventListener listener, final ScheduledExecutorService scheduler)
158164
throws EcovacsApiException {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Copyright (c) 2010-2024 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.ecovacs.internal.api.impl.dto.response.portal;
14+
15+
import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
16+
17+
import com.google.gson.annotations.SerializedName;
18+
19+
/**
20+
* @author Danny Baumann - Initial contribution
21+
*/
22+
public class PortalCleanLogRecord {
23+
@SerializedName("ts")
24+
public final long timestamp;
25+
26+
@SerializedName("last")
27+
public final long duration;
28+
29+
public final int area;
30+
31+
public final String id;
32+
33+
public final String imageUrl;
34+
35+
public final CleanMode type;
36+
37+
// more possible fields:
38+
// aiavoid (int), aitypes (list of something), aiopen (int), aq (int), mapName (string),
39+
// sceneName (string), triggerMode (int), powerMopType (int), enablePowerMop (int), cornerDeep (int)
40+
41+
PortalCleanLogRecord(long timestamp, long duration, int area, String id, String imageUrl, CleanMode type) {
42+
this.timestamp = timestamp;
43+
this.duration = duration;
44+
this.area = area;
45+
this.id = id;
46+
this.imageUrl = imageUrl;
47+
this.type = type;
48+
}
49+
}

bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalCleanLogsResponse.java

+2-31
Original file line numberDiff line numberDiff line change
@@ -14,48 +14,19 @@
1414

1515
import java.util.List;
1616

17-
import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
18-
1917
import com.google.gson.annotations.SerializedName;
2018

2119
/**
2220
* @author Johannes Ptaszyk - Initial contribution
2321
*/
2422
public class PortalCleanLogsResponse {
25-
public static class LogRecord {
26-
@SerializedName("ts")
27-
public final long timestamp;
28-
29-
@SerializedName("last")
30-
public final long duration;
31-
32-
public final int area;
33-
34-
public final String id;
35-
36-
public final String imageUrl;
37-
38-
public final CleanMode type;
39-
40-
// more possible fields: aiavoid (int), aitypes (list of something), stopReason (int)
41-
42-
LogRecord(long timestamp, long duration, int area, String id, String imageUrl, CleanMode type) {
43-
this.timestamp = timestamp;
44-
this.duration = duration;
45-
this.area = area;
46-
this.id = id;
47-
this.imageUrl = imageUrl;
48-
this.type = type;
49-
}
50-
}
51-
5223
@SerializedName("logs")
53-
public final List<LogRecord> records;
24+
public final List<PortalCleanLogRecord> records;
5425

5526
@SerializedName("ret")
5627
final String result;
5728

58-
PortalCleanLogsResponse(String result, List<LogRecord> records) {
29+
PortalCleanLogsResponse(String result, List<PortalCleanLogRecord> records) {
5930
this.result = result;
6031
this.records = records;
6132
}

0 commit comments

Comments
 (0)