Skip to content

Commit

Permalink
[ecovacs] Add support for new API for fetching cleaning logs (openhab…
Browse files Browse the repository at this point in the history
…#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 <dannybaumann@web.de>
  • Loading branch information
maniac103 authored and magx2 committed Mar 24, 2024
1 parent 0b386e7 commit 924c359
Show file tree
Hide file tree
Showing 15 changed files with 279 additions and 109 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class EcovacsBindingConstants {
public static final String CLIENT_SECRET = "6c319b2a5cd3e66e39159c2e28f2fce9";
public static final String AUTH_CLIENT_KEY = "1520391491841";
public static final String AUTH_CLIENT_SECRET = "77ef58ce3afbe337da74aa8c5ab963a9";
public static final String APP_KEY = "2ea31cf06e6711eaa0aff7b9558a534e";

// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "ecovacsapi");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
package org.openhab.binding.ecovacs.internal.api;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
import org.openhab.binding.ecovacs.internal.api.util.HashUtil;

/**
* @author Johannes Ptaszyk - Initial contribution
Expand All @@ -30,10 +30,12 @@ public final class EcovacsApiConfiguration {
private final String clientSecret;
private final String authClientKey;
private final String authClientSecret;
private final String appKey;

public EcovacsApiConfiguration(String deviceId, String username, String password, String continent, String country,
String language, String clientKey, String clientSecret, String authClientKey, String authClientSecret) {
this.deviceId = MD5Util.getMD5Hash(deviceId);
String language, String clientKey, String clientSecret, String authClientKey, String authClientSecret,
String appKey) {
this.deviceId = HashUtil.getMD5Hash(deviceId);
this.username = username;
this.password = password;
this.continent = continent;
Expand All @@ -43,6 +45,7 @@ public EcovacsApiConfiguration(String deviceId, String username, String password
this.clientSecret = clientSecret;
this.authClientKey = authClientKey;
this.authClientSecret = authClientSecret;
this.appKey = appKey;
}

public String getDeviceId() {
Expand Down Expand Up @@ -90,7 +93,7 @@ public String getRealm() {
return "ecouser.net";
}

public String getPortalAUthRequestWith() {
public String getPortalAuthRequestWith() {
return "users";
}

Expand All @@ -110,12 +113,28 @@ public String getChannel() {
return "google_play";
}

public String getAppId() {
return "ecovacs";
}

public String getAppPlatform() {
return "android";
}

public String getAppCode() {
return "global_e";
}

public String getAppVersion() {
return "1.6.3";
return "2.3.7";
}

public String getAppKey() {
return appKey;
}

public String getAppUserAgent() {
return "EcovacsHome/2.3.7 (Linux; U; Android 5.1.1; A5010 Build/LMY48Z)";
}

public String getDeviceType() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,6 @@ void connect(EventListener listener, ScheduledExecutorService scheduler)
<T> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException;

List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException;

Optional<byte[]> downloadCleanMapImage(CleanLogRecord record) throws EcovacsApiException, InterruptedException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,16 @@
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.AbstractPortalIotCommandResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.IotProduct;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogRecord;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogsResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanResultsResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalDeviceResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandJsonResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotProductResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
import org.openhab.binding.ecovacs.internal.api.util.HashUtil;
import org.openhab.core.OpenHAB;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -116,8 +118,8 @@ PortalLoginResponse getLoginData() {
private AccessData login() throws EcovacsApiException, InterruptedException {
HashMap<String, String> loginParameters = new HashMap<>();
loginParameters.put("account", configuration.getUsername());
loginParameters.put("password", MD5Util.getMD5Hash(configuration.getPassword()));
loginParameters.put("requestId", MD5Util.getMD5Hash(String.valueOf(System.currentTimeMillis())));
loginParameters.put("password", HashUtil.getMD5Hash(configuration.getPassword()));
loginParameters.put("requestId", HashUtil.getMD5Hash(String.valueOf(System.currentTimeMillis())));
loginParameters.put("authTimeZone", configuration.getTimeZone());
loginParameters.put("country", configuration.getCountry());
loginParameters.put("lang", configuration.getLanguage());
Expand Down Expand Up @@ -310,8 +312,7 @@ public <T> T sendIotCommand(Device device, DeviceDescription desc, IotDeviceComm
}
}

public List<PortalCleanLogsResponse.LogRecord> fetchCleanLogs(Device device)
throws EcovacsApiException, InterruptedException {
public List<PortalCleanLogRecord> fetchCleanLogs(Device device) throws EcovacsApiException, InterruptedException {
PortalCleanLogsRequest data = new PortalCleanLogsRequest(createAuthData(), device.getDid(),
device.getResource());
String url = EcovacsApiUrlFactory.getPortalLogUrl(configuration);
Expand All @@ -324,12 +325,39 @@ public List<PortalCleanLogsResponse.LogRecord> fetchCleanLogs(Device device)
return responseObj.records;
}

public List<PortalCleanLogRecord> fetchCleanResultsLog(Device device)
throws EcovacsApiException, InterruptedException {
String url = EcovacsApiUrlFactory.getPortalCleanResultsLogUrl(configuration);
Request request = createSignedAppRequest(url).param("auth", gson.toJson(createAuthData())) //
.param("channel", configuration.getChannel()) //
.param("did", device.getDid()) //
.param("defaultLang", "EN") //
.param("logType", "clean") //
.param("res", device.getResource()) //
.param("size", "20") //
.param("version", "v2");

ContentResponse response = executeRequest(request);
PortalCleanResultsResponse responseObj = handleResponse(response, PortalCleanResultsResponse.class);
if (!responseObj.wasSuccessful()) {
throw new EcovacsApiException("Fetching clean results failed");
}
logger.trace("{}: Fetching cleaning results yields {} records", device.getName(), responseObj.records.size());
return responseObj.records;
}

public byte[] downloadCleanMapImage(String url, boolean useSigning)
throws EcovacsApiException, InterruptedException {
Request request = useSigning ? createSignedAppRequest(url) : httpClient.newRequest(url).method(HttpMethod.GET);
return executeRequest(request).getContent();
}

private PortalAuthRequestParameter createAuthData() {
PortalLoginResponse loginData = this.loginData;
if (loginData == null) {
throw new IllegalStateException("Not logged in");
}
return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
return new PortalAuthRequestParameter(configuration.getPortalAuthRequestWith(), loginData.getUserId(),
configuration.getRealm(), loginData.getToken(), configuration.getResource());
}

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

signedRequestParameters.put("authAppkey", clientKey);
signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
signedRequestParameters.put("authSign", HashUtil.getMD5Hash(signOnText.toString()));

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

return request;
}

private Request createSignedAppRequest(String url) {
String timestamp = Long.toString(System.currentTimeMillis());
String signContent = configuration.getAppId() + configuration.getAppKey() + timestamp;
PortalLoginResponse loginData = this.loginData;
if (loginData == null) {
throw new IllegalStateException("Not logged in");
}
return httpClient.newRequest(url).method(HttpMethod.GET)
.header("Authorization", "Bearer " + loginData.getToken()) //
.header("token", loginData.getToken()) //
.header("appid", configuration.getAppId()) //
.header("plat", configuration.getAppPlatform()) //
.header("userid", loginData.getUserId()) //
.header("user-agent", configuration.getAppUserAgent()) //
.header("v", configuration.getAppVersion()) //
.header("country", configuration.getCountry()) //
.header("sign", HashUtil.getSHA256Hash(signContent)) //
.header("signType", "sha256") //
.param("et1", timestamp);
}

private Request createJsonRequest(String url, Object data) {
return httpClient.newRequest(url).method(HttpMethod.POST).header(HttpHeader.CONTENT_TYPE, "application/json")
.content(new StringContentProvider(gson.toJson(data)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ private EcovacsApiUrlFactory() {

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

private static final String PORTAL_USERS_PATH = "/users/user.do";
private static final String PORTAL_IOT_PRODUCT_PATH = "/pim/product/getProductIotMap";
private static final String PORTAL_IOT_DEVMANAGER_PATH = "/iot/devmanager.do";
private static final String PORTAL_LOG_PATH = "/lg/log.do";
private static final String PORTAL_USERS_PATH = "/api/users/user.do";
private static final String PORTAL_IOT_PRODUCT_PATH = "/api/pim/product/getProductIotMap";
private static final String PORTAL_IOT_DEVMANAGER_PATH = "/api/iot/devmanager.do";
private static final String PORTAL_LOG_PATH = "/api/lg/log.do";
private static final String PORTAL_CLEAN_RESULTS_PATH = "/app/dln/api/log/clean_result/list";

public static String getLoginUrl(EcovacsApiConfiguration config) {
return getMainUrl(config) + MAIN_URL_LOGIN_PATH;
Expand All @@ -57,9 +58,13 @@ public static String getPortalLogUrl(EcovacsApiConfiguration config) {
return getPortalUrl(config) + PORTAL_LOG_PATH;
}

public static String getPortalCleanResultsLogUrl(EcovacsApiConfiguration config) {
return getPortalUrl(config) + PORTAL_CLEAN_RESULTS_PATH;
}

private static String getPortalUrl(EcovacsApiConfiguration config) {
String continentSuffix = "cn".equalsIgnoreCase(config.getCountry()) ? "" : "-" + config.getContinent();
return String.format("https://portal%1$s.ecouser.net/api", continentSuffix);
return String.format("https://portal%1$s.ecouser.net", continentSuffix);
}

private static String getMainUrl(EcovacsApiConfiguration config) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.openhab.binding.ecovacs.internal.api.commands.GetFirmwareVersionCommand;
import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogRecord;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
Expand Down Expand Up @@ -103,12 +104,25 @@ public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, Interrupt
if (desc.protoVersion == ProtocolVersion.XML) {
logEntries = sendCommand(new GetCleanLogsCommand()).stream();
} else {
logEntries = api.fetchCleanLogs(device).stream().map(record -> new CleanLogRecord(record.timestamp,
record.duration, record.area, Optional.ofNullable(record.imageUrl), record.type));
List<PortalCleanLogRecord> log = hasCapability(DeviceCapability.USES_CLEAN_RESULTS_LOG_API)
? api.fetchCleanResultsLog(device)
: api.fetchCleanLogs(device);
logEntries = log.stream().map(record -> new CleanLogRecord(record.timestamp, record.duration, record.area,
Optional.ofNullable(record.imageUrl), record.type));
}
return logEntries.sorted((lhs, rhs) -> rhs.timestamp.compareTo(lhs.timestamp)).collect(Collectors.toList());
}

@Override
public Optional<byte[]> downloadCleanMapImage(CleanLogRecord record)
throws EcovacsApiException, InterruptedException {
if (record.mapImageUrl.isEmpty()) {
return Optional.empty();
}
boolean needsSigning = hasCapability(DeviceCapability.USES_CLEAN_RESULTS_LOG_API);
return Optional.of(api.downloadCleanMapImage(record.mapImageUrl.get(), needsSigning));
}

@Override
public void connect(final EventListener listener, ScheduledExecutorService scheduler)
throws EcovacsApiException, InterruptedException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, Interrupt
return sendCommand(new GetCleanLogsCommand());
}

@Override
public Optional<byte[]> downloadCleanMapImage(CleanLogRecord record)
throws EcovacsApiException, InterruptedException {
return Optional.empty();
}

@Override
public void connect(final EventListener listener, final ScheduledExecutorService scheduler)
throws EcovacsApiException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal;

import org.openhab.binding.ecovacs.internal.api.model.CleanMode;

import com.google.gson.annotations.SerializedName;

/**
* @author Danny Baumann - Initial contribution
*/
public class PortalCleanLogRecord {
@SerializedName("ts")
public final long timestamp;

@SerializedName("last")
public final long duration;

public final int area;

public final String id;

public final String imageUrl;

public final CleanMode type;

// more possible fields:
// aiavoid (int), aitypes (list of something), aiopen (int), aq (int), mapName (string),
// sceneName (string), triggerMode (int), powerMopType (int), enablePowerMop (int), cornerDeep (int)

PortalCleanLogRecord(long timestamp, long duration, int area, String id, String imageUrl, CleanMode type) {
this.timestamp = timestamp;
this.duration = duration;
this.area = area;
this.id = id;
this.imageUrl = imageUrl;
this.type = type;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,48 +14,19 @@

import java.util.List;

import org.openhab.binding.ecovacs.internal.api.model.CleanMode;

import com.google.gson.annotations.SerializedName;

/**
* @author Johannes Ptaszyk - Initial contribution
*/
public class PortalCleanLogsResponse {
public static class LogRecord {
@SerializedName("ts")
public final long timestamp;

@SerializedName("last")
public final long duration;

public final int area;

public final String id;

public final String imageUrl;

public final CleanMode type;

// more possible fields: aiavoid (int), aitypes (list of something), stopReason (int)

LogRecord(long timestamp, long duration, int area, String id, String imageUrl, CleanMode type) {
this.timestamp = timestamp;
this.duration = duration;
this.area = area;
this.id = id;
this.imageUrl = imageUrl;
this.type = type;
}
}

@SerializedName("logs")
public final List<LogRecord> records;
public final List<PortalCleanLogRecord> records;

@SerializedName("ret")
final String result;

PortalCleanLogsResponse(String result, List<LogRecord> records) {
PortalCleanLogsResponse(String result, List<PortalCleanLogRecord> records) {
this.result = result;
this.records = records;
}
Expand Down
Loading

0 comments on commit 924c359

Please sign in to comment.