[ecovacs] Add support for Deebot X8 (#19750)

* [ecovacs] Handle error JSON during map image download

Signed-off-by: Danny Baumann <dannybaumann@web.de>
This commit is contained in:
maniac103
2025-12-06 18:30:12 +01:00
committed by GitHub
parent afe6994534
commit 59b21c7e3c
15 changed files with 242 additions and 8 deletions
@@ -67,6 +67,7 @@ In case a particular channel is not supported by a given device (see remarks), i
| Channel | Type | Description | Read Only | Updated By | Remarks |
|-----------------------------------------|----------------------|-----------------------------------------------------------|-----------|------------|----------|
| actions#command | String | Command to execute | No | Event | [1] |
| status#battery | Number | Current battery level | Yes | Event | |
| status#state | String | Current operational state | Yes | Event | [2] |
| status#current-cleaning-mode | String | Mode used in current cleaning run | Yes | Event | [3], [4] |
| status#current-cleaning-time | Number:Time | Time spent in current cleaning run | Yes | Event | [4] |
@@ -94,6 +95,7 @@ In case a particular channel is not supported by a given device (see remarks), i
| settings#true-detect-3d | Switch | Whether True Detect 3D is enabled | No | Polling | [13] |
| settings#voice-volume | Dimmer | The voice volume level in percent | No | Polling | [14] |
| settings#water-amount | String | The amount of water to be used when mopping | No | Polling | [15] |
| settings#water-amount-percent | Dimmer | The amount of water to be used when mopping (in percent) | No | Polling | [16] |
Remarks:
@@ -112,6 +114,7 @@ Remarks:
- [13] Only present if device supports True Detect 3D
- [14] Only present if device has voice reporting
- [15] Only present if device has a mopping system. Possible values include `low`, `medium`, `high` and `veryhigh`
- [16] Only present on Deebot X8 or newer.
## Command Channel Actions
@@ -74,6 +74,7 @@ public class EcovacsBindingConstants {
public static final String CHANNEL_ID_VOICE_VOLUME = "settings#voice-volume";
public static final String CHANNEL_ID_WATER_PLATE_PRESENT = "status#water-system-present";
public static final String CHANNEL_ID_WATER_AMOUNT = "settings#water-amount";
public static final String CHANNEL_ID_WATER_AMOUNT_PERCENT = "settings#water-amount-percent";
public static final String CHANNEL_ID_WIFI_RSSI = "status#wifi-rssi";
public static final String CHANNEL_TYPE_ID_CLEAN_MODE = "current-cleaning-mode";
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2010-2025 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.commands;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ecovacs.internal.api.impl.ProtocolVersion;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.WaterInfoReport;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.AbstractPortalIotCommandResponse;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandJsonResponse;
import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
import com.google.gson.Gson;
/**
* @author Danny Baumann - Initial contribution
*/
@NonNullByDefault
public class GetCustomMoppingWaterAmountCommand extends IotDeviceCommand<Integer> {
@Override
public String getName(ProtocolVersion version) {
if (version != ProtocolVersion.JSON_V2) {
throw new IllegalStateException("Get custom water amount is only supported for JSON V2");
}
return "getWaterInfo";
}
@Override
public Integer convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
throws DataParsingException {
PortalIotCommandJsonResponse jsonResponse = (PortalIotCommandJsonResponse) response;
WaterInfoReport resp = jsonResponse.getResponsePayloadAs(gson, WaterInfoReport.class);
return resp.customWaterAmount;
}
}
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2025 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.commands;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ecovacs.internal.api.impl.ProtocolVersion;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
/**
* @author Danny Baumann - Initial contribution
*/
@NonNullByDefault
public class SetCustomMoppingWaterAmountCommand extends AbstractNoResponseCommand {
private final int amount;
public SetCustomMoppingWaterAmountCommand(int amount) {
this.amount = amount;
}
@Override
public String getName(ProtocolVersion version) {
if (version != ProtocolVersion.JSON_V2) {
throw new IllegalStateException("Set custom water amount is only supported for JSON V2");
}
return "setWaterInfo";
}
@Override
protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
JsonObject args = new JsonObject();
args.addProperty("customAmount", amount);
return args;
}
}
@@ -346,10 +346,16 @@ public final class EcovacsApiImpl implements EcovacsApi {
return responseObj.records;
}
public byte[] downloadCleanMapImage(String url, boolean useSigning)
public Optional<byte[]> downloadCleanMapImage(Device device, String url, boolean useSigning)
throws EcovacsApiException, InterruptedException {
Request request = useSigning ? createSignedAppRequest(url) : httpClient.newRequest(url).method(HttpMethod.GET);
return executeRequest(request).getContent();
ContentResponse response = executeRequest(request);
if ("application/json".equals(response.getMediaType())) {
logger.warn("{}: Could not download map image {}: {}", device.getName(), url,
response.getContentAsString());
return Optional.empty();
}
return Optional.of(response.getContent());
}
private PortalAuthRequestParameter createAuthData() {
@@ -120,7 +120,7 @@ public class EcovacsIotMqDevice implements EcovacsDevice {
return Optional.empty();
}
boolean needsSigning = hasCapability(DeviceCapability.USES_CLEAN_RESULTS_LOG_API);
return Optional.of(api.downloadCleanMapImage(record.mapImageUrl.get(), needsSigning));
return api.downloadCleanMapImage(device, record.mapImageUrl.get(), needsSigning);
}
@Override
@@ -25,6 +25,7 @@ import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.ErrorReport;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.StatsReport;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.WaterInfoReport;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.WorkStateReport;
import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandJsonResponse.JsonResponsePayloadWrapper;
import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
@@ -128,6 +129,15 @@ class JsonReportParser implements ReportParser {
listener.onWaterSystemPresentUpdated(device, report.waterPlatePresent != 0);
break;
}
case "workstate": {
WorkStateReport report = payloadAs(response, WorkStateReport.class);
CleanMode mode = report.determineCleanMode(gson);
if (mode == null) {
throw new DataParsingException("Could not get clean mode from response " + payload);
}
handleCleanModeChange(mode, null);
break;
}
// more possible events (unused for now):
// - "evt" -> EventReport
// - "lifespan" -> ComponentLifeSpanReport
@@ -21,5 +21,7 @@ public class WaterInfoReport {
@SerializedName("enable")
public int waterPlatePresent;
@SerializedName("amount")
public int waterAmount;
public Integer waterAmount; // enum (1-4)
@SerializedName("customAmount")
public Integer customWaterAmount; // numeric value (0-100)
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2010-2025 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.deviceapi.json;
import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
/**
* @author Danny Baumann - Initial contribution
*/
public class WorkStateReport {
@SerializedName("paused")
public int paused;
@SerializedName("robotState")
public RobotState robotState;
@SerializedName("stationState")
public DeviceState stationState;
public static class DeviceState {
@SerializedName("trigger")
public String trigger; // app, workComplete, voice, ...?
@SerializedName("state")
public String state;
}
public static class RobotState extends DeviceState {
@SerializedName("cleanState")
public CleanStateReport cleanState;
}
public static class CleanStateReport {
@SerializedName("cid")
public String id;
@SerializedName("type")
public String type;
}
public CleanMode determineCleanMode(Gson gson) {
if (paused != 0) {
return CleanMode.PAUSE;
}
final String modeValue;
if ("cleaning".equals(robotState.state) && robotState.cleanState != null) {
modeValue = robotState.cleanState.type;
} else if ("idle".equals(robotState.state)) {
modeValue = stationState.state;
} else {
modeValue = robotState.state;
}
return gson.fromJson(modeValue, CleanMode.class);
}
}
@@ -33,7 +33,7 @@ public enum CleanMode {
CUSTOM_AREA,
@SerializedName("singleRoom")
SINGLE_ROOM,
@SerializedName("sceneClean")
@SerializedName(value = "sceneClean", alternate = { "qcClean" })
SCENE_CLEAN,
@SerializedName("pause")
PAUSE,
@@ -41,7 +41,7 @@ public enum CleanMode {
STOP,
@SerializedName(value = "going", alternate = { "goCharging" })
RETURNING,
@SerializedName("autoEmpty")
@SerializedName(value = "autoEmpty", alternate = { "emptying" })
EMPTYING,
@SerializedName("washing")
WASHING,
@@ -55,6 +55,8 @@ public enum DeviceCapability {
ROUND_MOP_LIFESPAN,
@SerializedName("air_drying")
AIR_DRYING,
@SerializedName("custom_water_amount")
CUSTOM_WATER_AMOUNT,
// implicit capabilities added in code
EDGE_CLEANING,
SPOT_CLEANING,
@@ -40,6 +40,7 @@ import org.openhab.binding.ecovacs.internal.api.commands.GetChargeStateCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetCleanStateCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetComponentLifeSpanCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetContinuousCleaningCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetCustomMoppingWaterAmountCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetDefaultCleanPassesCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetDustbinAutoEmptyCommand;
import org.openhab.binding.ecovacs.internal.api.commands.GetErrorCommand;
@@ -57,6 +58,7 @@ import org.openhab.binding.ecovacs.internal.api.commands.PlaySoundCommand;
import org.openhab.binding.ecovacs.internal.api.commands.ResumeCleaningCommand;
import org.openhab.binding.ecovacs.internal.api.commands.SceneCleaningCommand;
import org.openhab.binding.ecovacs.internal.api.commands.SetContinuousCleaningCommand;
import org.openhab.binding.ecovacs.internal.api.commands.SetCustomMoppingWaterAmountCommand;
import org.openhab.binding.ecovacs.internal.api.commands.SetDefaultCleanPassesCommand;
import org.openhab.binding.ecovacs.internal.api.commands.SetDustbinAutoEmptyCommand;
import org.openhab.binding.ecovacs.internal.api.commands.SetMoppingWaterAmountCommand;
@@ -187,6 +189,9 @@ public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDev
device.sendCommand(new SetMoppingWaterAmountCommand(amount.get()));
return;
}
} else if (channel.equals(CHANNEL_ID_WATER_AMOUNT_PERCENT) && command instanceof PercentType percent) {
device.sendCommand(new SetCustomMoppingWaterAmountCommand(percent.intValue()));
return;
} else if (channel.equals(CHANNEL_ID_AUTO_EMPTY)) {
if (command instanceof OnOffType) {
device.sendCommand(new SetDustbinAutoEmptyCommand(command == OnOffType.ON));
@@ -411,7 +416,12 @@ public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDev
if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT_PERCENT);
hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_PLATE_PRESENT);
} else if (device.hasCapability(DeviceCapability.CUSTOM_WATER_AMOUNT)) {
hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
} else {
hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT_PERCENT);
}
if (!device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_SUCTION_POWER);
@@ -615,8 +625,14 @@ public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDev
}
if (device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
MoppingWaterAmount waterAmount = device.sendCommand(new GetMoppingWaterAmountCommand());
updateState(CHANNEL_ID_WATER_AMOUNT, new StringType(WATER_AMOUNT_MAPPING.getMappedValue(waterAmount)));
if (device.hasCapability(DeviceCapability.CUSTOM_WATER_AMOUNT)) {
Integer waterAmount = device.sendCommand(new GetCustomMoppingWaterAmountCommand());
updateState(CHANNEL_ID_WATER_AMOUNT_PERCENT, new PercentType(waterAmount));
} else {
MoppingWaterAmount waterAmount = device.sendCommand(new GetMoppingWaterAmountCommand());
updateState(CHANNEL_ID_WATER_AMOUNT,
new StringType(WATER_AMOUNT_MAPPING.getMappedValue(waterAmount)));
}
}
if (device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
@@ -108,6 +108,7 @@ channel-type.ecovacs.true-detect-3d.label = True Detect 3D
channel-type.ecovacs.true-detect-3d.description = Enable the True Detect 3D object recognition technology
channel-type.ecovacs.voice-volume.label = Voice Volume
channel-type.ecovacs.voice-volume.description = Volume level of voice reports
channel-type.ecovacs.water-amount-percent.label = Mopping Water Amount
channel-type.ecovacs.water-amount.label = Mopping Water Amount
channel-type.ecovacs.water-amount.state.option.low = Low
channel-type.ecovacs.water-amount.state.option.medium = Medium
@@ -98,6 +98,7 @@
<label>Settings</label>
<channels>
<channel id="water-amount" typeId="water-amount"/>
<channel id="water-amount-percent" typeId="water-amount-percent"/>
<channel id="suction-power" typeId="suction-power"/>
<channel id="voice-volume" typeId="voice-volume"/>
<channel id="auto-empty" typeId="auto-empty"/>
@@ -346,6 +347,12 @@
</state>
</channel-type>
<channel-type id="water-amount-percent" advanced="true">
<item-type>Dimmer</item-type>
<label>Mopping Water Amount</label>
<state pattern="%d%%" min="1" max="100"/>
</channel-type>
<channel-type id="wifi-rssi" advanced="true">
<item-type unitHint="dBm">Number:Power</item-type>
<label>Wi-Fi Signal Strength</label>
@@ -949,5 +949,36 @@
"modelName": "DEEBOT N30 PRO OMNI",
"deviceClass": "87swps",
"deviceClassLink": "z4lvk7"
},
{
"modelName": "DEEBOT X8 PRO OMNI",
"deviceClass": "n0vyif",
"protoVersion": "json_v2",
"usesMqtt": true,
"capabilities": [
"mopping_system",
"main_brush",
"spot_area_cleaning",
"free_clean_command",
"custom_area_cleaning",
"scenario_cleaning",
"clean_speed_control",
"voice_reporting",
"read_network_info",
"unit_care_lifespan",
"round_mop_lifespan",
"true_detect_3d",
"mapping",
"auto_empty_station",
"air_drying",
"uses_clean_results_log_api",
"custom_water_amount"
]
},
{
"modelName": "DEEBOT X8 OMNI",
"deviceClass": "4bx3w9",
"deviceClassLink": "n0vyif"
}
]