[ecovacs] Add support for new API for fetching cleaning logs (#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>
This commit is contained in:
maniac103 2024-03-17 20:47:36 +01:00 committed by GitHub
parent 3d65b3003f
commit 67daa7eca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 289 additions and 119 deletions

View File

@ -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");

View File

@ -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
@ -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;
@ -43,6 +45,7 @@ public final class EcovacsApiConfiguration {
this.clientSecret = clientSecret;
this.authClientKey = authClientKey;
this.authClientSecret = authClientSecret;
this.appKey = appKey;
}
public String getDeviceId() {
@ -90,7 +93,7 @@ public final class EcovacsApiConfiguration {
return "ecouser.net";
}
public String getPortalAUthRequestWith() {
public String getPortalAuthRequestWith() {
return "users";
}
@ -110,12 +113,28 @@ public final class EcovacsApiConfiguration {
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() {

View File

@ -59,4 +59,6 @@ public interface EcovacsDevice {
<T> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException;
List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException;
Optional<byte[]> downloadCleanMapImage(CleanLogRecord record) throws EcovacsApiException, InterruptedException;
}

View File

@ -59,14 +59,16 @@ import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.ResponseW
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;
@ -116,8 +118,8 @@ public final class EcovacsApiImpl implements EcovacsApi {
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());
@ -310,8 +312,7 @@ public final class EcovacsApiImpl implements EcovacsApi {
}
}
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);
@ -324,12 +325,39 @@ public final class EcovacsApiImpl implements EcovacsApi {
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());
}
@ -371,7 +399,7 @@ public final class EcovacsApiImpl implements EcovacsApi {
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);
@ -379,6 +407,27 @@ public final class EcovacsApiImpl implements EcovacsApi {
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)));

View File

@ -27,10 +27,11 @@ public final class 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;
@ -57,9 +58,13 @@ public final class EcovacsApiUrlFactory {
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) {

View File

@ -34,6 +34,7 @@ import org.openhab.binding.ecovacs.internal.api.commands.GetCleanLogsCommand;
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;
@ -103,12 +104,25 @@ public class EcovacsIotMqDevice implements EcovacsDevice {
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 {

View File

@ -153,6 +153,12 @@ public class EcovacsXmppDevice implements EcovacsDevice {
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 {

View File

@ -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;
}
}

View File

@ -14,48 +14,19 @@ package org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal;
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;
}

View File

@ -0,0 +1,38 @@
/**
* 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 java.util.List;
import com.google.gson.annotations.SerializedName;
/**
* @author Danny Baumann - Initial contribution
*/
public class PortalCleanResultsResponse {
@SerializedName("data")
public final List<PortalCleanLogRecord> records;
final int code;
final String message;
PortalCleanResultsResponse(int code, String message, List<PortalCleanLogRecord> records) {
this.code = code;
this.message = message;
this.records = records;
}
public boolean wasSuccessful() {
return code == 0;
}
}

View File

@ -47,6 +47,8 @@ public enum DeviceCapability {
TRUE_DETECT_3D,
@SerializedName("unit_care_lifespan")
UNIT_CARE_LIFESPAN,
@SerializedName("uses_clean_results_log_api")
USES_CLEAN_RESULTS_LOG_API,
// implicit capabilities added in code
EDGE_CLEANING,
SPOT_CLEANING,

View File

@ -23,30 +23,36 @@ import org.slf4j.LoggerFactory;
* @author Johannes Ptaszyk - Initial contribution
*/
@NonNullByDefault
public class MD5Util {
private static final Logger LOGGER = LoggerFactory.getLogger(MD5Util.class);
public class HashUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(HashUtil.class);
private MD5Util() {
private HashUtil() {
// Prevent instantiation of util class
}
public static String getMD5Hash(String input) {
return calculateHash("MD5", input);
}
public static String getSHA256Hash(String input) {
return calculateHash("SHA-256", input);
}
private static String calculateHash(String algorithm, String input) {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
md = MessageDigest.getInstance(algorithm);
} catch (NoSuchAlgorithmException e) {
LOGGER.error("Could not get MD5 MessageDigest instance", e);
LOGGER.error("Could not get {} MessageDigest instance", algorithm, e);
return "";
}
md.update(input.getBytes());
byte[] hash = md.digest();
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
if ((0xff & b) < 0x10) {
hexString.append("0").append(Integer.toHexString((0xFF & b)));
} else {
hexString.append(Integer.toHexString(0xFF & b));
for (byte b : md.digest()) {
if ((b & 0xff) < 0x10) {
hexString.append("0");
}
hexString.append(Integer.toHexString(b & 0xff));
}
return hexString.toString();
}

View File

@ -122,7 +122,7 @@ public class EcovacsApiHandler extends BaseBridgeHandler {
String deviceId = config.installId + deviceIdSuffix;
org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration apiConfig = new org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration(
deviceId, config.email, config.password, config.continent, country, "EN", CLIENT_KEY, CLIENT_SECRET,
AUTH_CLIENT_KEY, AUTH_CLIENT_SECRET);
AUTH_CLIENT_KEY, AUTH_CLIENT_SECRET, APP_KEY);
return EcovacsApi.create(httpClient, apiConfig);
}

View File

@ -80,7 +80,6 @@ import org.openhab.binding.ecovacs.internal.util.StateOptionMapping;
import org.openhab.core.i18n.ConfigurationException;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
@ -594,19 +593,11 @@ public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDev
if (device.hasCapability(DeviceCapability.MAPPING)
&& !lastDownloadedCleanMapUrl.equals(record.mapImageUrl)) {
updateState(CHANNEL_ID_LAST_CLEAN_MAP, record.mapImageUrl.flatMap(url -> {
// HttpUtil expects the server to return the correct MIME type, but Ecovacs' server sends
// 'application/octet-stream', so we have to set the correct MIME type by ourselves
@Nullable
RawType mapData = HttpUtil.downloadData(url, null, false, -1);
if (mapData != null) {
mapData = new RawType(mapData.getBytes(), "image/png");
lastDownloadedCleanMapUrl = record.mapImageUrl;
} else {
logger.debug("{}: Downloading cleaning map {} failed", serialNumber, url);
}
return Optional.ofNullable((State) mapData);
}).orElse(UnDefType.NULL));
Optional<State> content = device.downloadCleanMapImage(record).map(bytes -> {
lastDownloadedCleanMapUrl = record.mapImageUrl;
return new RawType(bytes, "image/png");
});
updateState(CHANNEL_ID_LAST_CLEAN_MAP, content.orElse(UnDefType.NULL));
}
}
}

View File

@ -354,31 +354,6 @@
"deviceClass": "vdehg6",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT T9+",
"deviceClass": "lhbd50",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT T9+",
"deviceClass": "um2ywg",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT T9 AIVI",
"deviceClass": "8kwdb4",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT T9 AIVI",
"deviceClass": "659yh8",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT T9 AIVI Plus",
"deviceClass": "kw9ayx",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT N8 PRO+",
"deviceClass": "85as7h",
@ -389,90 +364,131 @@
"deviceClass": "ifbw08",
"deviceClassLink": "fqxoiu"
},
{
"modelName": "DEEBOT T9+",
"deviceClass": "lhbd50",
"protoVersion": "json_v2",
"usesMqtt": true,
"capabilities": [
"mopping_system",
"main_brush",
"spot_area_cleaning",
"custom_area_cleaning",
"clean_speed_control",
"voice_reporting",
"read_network_info",
"unit_care_lifespan",
"true_detect_3d",
"mapping",
"auto_empty_station",
"uses_clean_results_log_api"
]
},
{
"modelName": "DEEBOT T9+",
"deviceClass": "um2ywg",
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T9 AIVI",
"deviceClass": "8kwdb4",
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T9 AIVI",
"deviceClass": "659yh8",
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T9 AIVI Plus",
"deviceClass": "kw9ayx",
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT N9+",
"deviceClass": "a7lhb1",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT N9+",
"deviceClass": "c2of2s",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1",
"deviceClass": "3yqsch",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T10",
"deviceClass": "jtmf04",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T10 PLUS",
"deviceClass": "rss8xk",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T10 PLUS",
"deviceClass": "p95mgv",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T10 TURBO",
"deviceClass": "9s1s80",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T10 OMNI",
"deviceClass": "lx3j7m",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1 OMNI",
"deviceClass": "8bja83",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1 OMNI",
"deviceClass": "1b23du",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1 OMNI",
"deviceClass": "1vxt52",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1 TURBO",
"deviceClass": "2o4lnm",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1 PLUS",
"deviceClass": "n4gstt",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT X1e OMNI",
"deviceClass": "bro5wu",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T20 OMNI",
"deviceClass": "p1jij8",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT N10 PLUS",
"deviceClass": "umwv6z",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT N10 MAX+",
"deviceClass": "clojes",
"deviceClassLink": "fqxoiu"
"deviceClassLink": "lhbd50"
},
{
@ -576,7 +592,8 @@
"unit_care_lifespan",
"true_detect_3d",
"mapping",
"auto_empty_station"
"auto_empty_station",
"uses_clean_results_log_api"
]
},
{