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");
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
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;
this.clientSecret = clientSecret;
this.authClientKey = authClientKey;
this.authClientSecret = authClientSecret;
+ this.appKey = appKey;
}
public String getDeviceId() {
return "ecouser.net";
}
- public String getPortalAUthRequestWith() {
+ public String getPortalAuthRequestWith() {
return "users";
}
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() {
<T> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException;
List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException;
+
+ Optional<byte[]> downloadCleanMapImage(CleanLogRecord record) throws EcovacsApiException, InterruptedException;
}
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;
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());
}
}
- 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);
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());
}
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)));
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;
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) {
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;
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 {
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 {
--- /dev/null
+/**
+ * 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;
+ }
+}
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;
}
--- /dev/null
+/**
+ * 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;
+ }
+}
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,
--- /dev/null
+/**
+ * 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.util;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public class HashUtil {
+ private static final Logger LOGGER = LoggerFactory.getLogger(HashUtil.class);
+
+ 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(algorithm);
+ } catch (NoSuchAlgorithmException e) {
+ LOGGER.error("Could not get {} MessageDigest instance", algorithm, e);
+ return "";
+ }
+ md.update(input.getBytes());
+ StringBuilder hexString = new StringBuilder();
+ for (byte b : md.digest()) {
+ if ((b & 0xff) < 0x10) {
+ hexString.append("0");
+ }
+ hexString.append(Integer.toHexString(b & 0xff));
+ }
+ return hexString.toString();
+ }
+}
+++ /dev/null
-/**
- * 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.util;
-
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * @author Johannes Ptaszyk - Initial contribution
- */
-@NonNullByDefault
-public class MD5Util {
- private static final Logger LOGGER = LoggerFactory.getLogger(MD5Util.class);
-
- private MD5Util() {
- // Prevent instantiation of util class
- }
-
- public static String getMD5Hash(String input) {
- MessageDigest md;
- try {
- md = MessageDigest.getInstance("MD5");
- } catch (NoSuchAlgorithmException e) {
- LOGGER.error("Could not get MD5 MessageDigest instance", 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));
- }
- }
- return hexString.toString();
- }
-}
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);
}
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;
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));
}
}
}
"deviceClass": "vdehg6",
"deviceClassLink": "fqxoiu"
},
+ {
+ "modelName": "DEEBOT N8 PRO+",
+ "deviceClass": "85as7h",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT N8 PRO+",
+ "deviceClass": "ifbw08",
+ "deviceClassLink": "fqxoiu"
+ },
+
{
"modelName": "DEEBOT T9+",
"deviceClass": "lhbd50",
- "deviceClassLink": "fqxoiu"
+ "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": "fqxoiu"
+ "deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T9 AIVI",
"deviceClass": "8kwdb4",
- "deviceClassLink": "fqxoiu"
+ "deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T9 AIVI",
"deviceClass": "659yh8",
- "deviceClassLink": "fqxoiu"
+ "deviceClassLink": "lhbd50"
},
{
"modelName": "DEEBOT T9 AIVI Plus",
"deviceClass": "kw9ayx",
- "deviceClassLink": "fqxoiu"
- },
- {
- "modelName": "DEEBOT N8 PRO+",
- "deviceClass": "85as7h",
- "deviceClassLink": "fqxoiu"
- },
- {
- "modelName": "DEEBOT N8 PRO+",
- "deviceClass": "ifbw08",
- "deviceClassLink": "fqxoiu"
+ "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"
},
{
"unit_care_lifespan",
"true_detect_3d",
"mapping",
- "auto_empty_station"
+ "auto_empty_station",
+ "uses_clean_results_log_api"
]
},
{