diff --git a/CODEOWNERS b/CODEOWNERS
index 5be07e4077702..c2270388ba127 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -82,6 +82,7 @@
/bundles/org.openhab.binding.echonetlite/ @mikeb01
/bundles/org.openhab.binding.ecobee/ @mhilbush
/bundles/org.openhab.binding.ecotouch/ @sibbi77
+/bundles/org.openhab.binding.ecovacs/ @maniac103
/bundles/org.openhab.binding.ecowatt/ @lolodomo
/bundles/org.openhab.binding.ekey/ @hmerk
/bundles/org.openhab.binding.electroluxair/ @jannegpriv
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index d385f693c87fa..d968a3512444d 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -406,6 +406,11 @@
org.openhab.binding.ecotouch${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.ecovacs
+ ${project.version}
+ org.openhab.addons.bundlesorg.openhab.binding.ecowatt
diff --git a/bundles/org.openhab.binding.ecovacs/NOTICE b/bundles/org.openhab.binding.ecovacs/NOTICE
new file mode 100644
index 0000000000000..38d625e349232
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.ecovacs/README.md b/bundles/org.openhab.binding.ecovacs/README.md
new file mode 100644
index 0000000000000..98284ba23de56
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/README.md
@@ -0,0 +1,175 @@
+# Ecovacs Binding
+
+This binding provides integration for vacuum cleaning / mopping robots made by Ecovacs ().
+It discovers devices and communicates to them by using Ecovacs' cloud services.
+
+## Supported Things
+
+- Ecovacs cloud API (`ecovacsapi`)
+- Vacuum cleaner (`vacuum`)
+
+At this point, the following devices are fully supported and verified to be working:
+
+- Deebot OZMO 900/905
+- Deebot OZMO 920
+- Deebot OZMO 930
+- Deebot OZMO 950
+- Deebot OZMO Slim 10/11
+- Deebot N8 series
+
+The following devices will likely work because they are using similar protocols as the above ones:
+
+- Deebot 600/601/605
+- Deebot 900/901
+- Deebot OZMO 610
+- Deebot 710/711/711s
+- Deebot OZMO T5
+- Deebot (OZMO) T8 series
+- Deebot T9 series
+- Deebot Slim 2
+- Deebot N3 MAX
+- Deebot N7
+- Deebot U2 series
+- Deebot X1 Omni
+
+## Discovery
+
+At first, you need to manually create the bridge thing for the cloud API.
+Once that is done, the supported devices will be automatically discovered and added to the inbox.
+
+## Thing Configuration
+
+For the cloud API thing, the following parameters must be configured:
+
+| Config | Description |
+|-----------|-------------------------------------------------------------------------------------------------------------------------------|
+| email | The email address you used when registering the Ecovacs cloud account |
+| password | The cloud account password |
+| continent | The continent you are residing on, or 'World' if none matches. This is used to select the correct cloud server to connect to. |
+
+For the vacuum things, there is no required configuration (when using discovery). The following parameters exist:
+
+| Config | Description |
+|--------------|-------------------------------------------------------------------------------------------------------------------------------|
+| serialNumber | Required: The device's serial number as printed on the barcode below the dust bin. Filled automatically when using discovery. |
+| refresh | Refresh interval for polled data (see below), in minutes. By default set to 5 minutes. |
+
+## Channels
+
+The list below lists all channels supported by the binding.
+In case a particular channel is not supported by a given device (see remarks), it is automatically removed from the given thing.
+
+| Channel | Type | Description | Read Only | Updated By | Remarks |
+|-----------------------------------------|----------------------|-----------------------------------------------------------|-----------|------------|----------|
+| actions#command | String | Command to execute | No | Event | [1] |
+| 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] |
+| status#current-cleaned-area | Number:Area | Area cleaned in current cleaning run | Yes | Event | [4] |
+| status#current-cleaning-spot-definition | String | The spot to clean in current cleaning run | Yes | Event | [4], [5] |
+| status#water-system-present | Switch | Whether the device is currently ready for mopping | Yes | Event | [6] |
+| status#wifi-rssi | Number:Power | The current Wi-Fi signal strength of the device | Yes | Polling | [7] |
+| consumables#main-brush-lifetime | Number:Dimensionless | The remaining life time of the main brush in percent | Yes | Polling | [8] |
+| consumables#side-brush-lifetime | Number:Dimensionless | The remaining life time of the side brush in percent | Yes | Polling | |
+| consumables#dust-filter-lifetime | Number:Dimensionless | The remaining life time of the dust bin filter in percent | Yes | Polling | |
+| consumables#other-component-lifetime | Number:Dimensionless | The remaining time until device maintenance in percent | Yes | Polling | [9] |
+| last-clean#last-clean-start | DateTime | The start time of the last completed cleaning run | Yes | Polling | |
+| last-clean#last-clean-duration | Number:Time | The duration of the last completed cleaning run | Yes | Polling | |
+| last-clean#last-clean-area | Number:Area | The area cleaned in the last completed cleaning run | Yes | Polling | |
+| last-clean#last-clean-mode | String | The mode used for the last completed cleaning run | Yes | Polling | [3] |
+| last-clean#last-clean-map | Image | The map image of the last completed cleaning run | Yes | Polling | |
+| total-stats#total-cleaning-time | Number:Time | The total time spent cleaning during the device life time | Yes | Polling | |
+| total-stats#total-cleaned-area | Number:Area | The total area cleaned during the device life time | Yes | Polling | |
+| total-stats#total-clean-runs | Number | The total number of clean runs in the device life time | Yes | Polling | |
+| settings#auto-empty | Switch | Whether dust bin auto empty to station is enabled | No | Polling | [10] |
+| settings#cleaning-passes | Number | Number of cleaning passes to be used (1 or 2) | No | Polling | [9] |
+| settings#continuous-cleaning | Switch | Whether unfinished cleaning resumes after charging | No | Polling | |
+| settings#suction-power | String | The power level used during cleaning | No | Polling | [11] |
+| settings#true-detect-3d | Switch | Whether True Detect 3D is enabled | No | Polling | [12] |
+| settings#voice-volume | Dimmer | The voice volume level in percent | No | Polling | [13] |
+| settings#water-amount | String | The amount of water to be used when mopping | No | Polling | [14] |
+
+Remarks:
+
+- [1] See [section below](#command-channel-actions)
+- [2] Possible states: 'cleaning', 'pause', 'stop', 'drying', 'washing', 'returning' and 'charging' (where 'drying' and 'washing' are only available on newer models with auto empty station)
+- [3] Possible states: 'auto', 'edge', 'spot', 'spotArea', 'customArea', 'singleRoom' (some of which depend on device capabilities)
+- [4] Current cleaning status is only valid if the device is currently cleaning
+- [5] Only valid for 'spot', 'spotArea' and 'customArea' cleaning modes; value can be used for 'spotArea' and 'customArea' commands (see below)
+- [6] Only present if device has a mopping system
+- [7] Only present on newer generation devices (Deebot OZMO 950 and newer)
+- [8] Only present if device has a main brush
+- [9] Only present on newer generation devices (Deebot N8/T8 or newer)
+- [10] Only present if device has a dustbin auto empty station; supports both on/off command (to turn on/off the setting) and the string 'trigger' (to trigger immediate auto empty)
+- [11] Only present if device can control power level. Possible values vary by device: 'normal' and 'high' are always supported, 'silent' and 'higher' are supported for some models
+- [12] Only present if device supports True Detect 3D
+- [13] Only present if device has voice reporting
+- [14] Only present if device has a mopping system. Possible values include 'low', 'medium', 'high' and 'veryhigh'
+
+## Command Channel Actions
+
+The following actions are supported by the `command` channel:
+
+| Name | Action | Remarks |
+|--------------|-------------------------------------------|------------------------------------------------------|
+| `clean` | Start cleaning in automatic mode. | |
+| `spotArea` | Start cleaning specific rooms. |
Only if supported by device, which can be recognized by `spotArea` being present in the list of possible states of the `current-cleaning-mode` channel.
Format: `spotArea:`, where `room IDs` is a semicolon separated list of room letters as shown in Ecovacs' app, so a valid command could e.g. be `spotArea:A;D;E`.
If you want to run 2 clean passes, amend `:x2` to the command, e.g. `spotArea:A;C;B:x2`.
|
+| `customArea` | Start cleaning specific areas. |
Only if supported by device, which can be recognized by `customArea` being present in the list of possible states of the `current-cleaning-mode` channel.
Format: `customArea:;;;, where the parameters are coordinates (in mm) relative to the map.
The coordinates can be obtained from the `current-cleaning-spot-definition` channel when starting a custom area run from the app.
If you want to run 2 clean passes, amend `:x2` to the command, e.g. `customArea:100;100;1000;1000:x2`.
|
+| `pause` | Pause cleaning if it's currently active. | If the device is idle, the command is ignored. |
+| `resume` | Resume cleaning if it's currently paused. | If the device is not paused, the command is ignored. |
+| `stop` | Stop cleaning immediately. | |
+| `charge` | Send device to charging station. | |
+
+## Rule actions
+
+This binding includes a rule action, which allows playback of specific sounds on the device in case the device has a speaker.
+There is a separate instance for each device, which can be retrieved like this:
+
+```java
+val vacuumActions = getActions("ecovacs","ecovacs:vacuum:1234567890")
+```
+
+where the first parameter always has to be `ecovacs` and the second is the full Thing UID of the device that should be used.
+Once this action instance is retrieved, you can invoke the `playSound(String type)` method on it:
+
+```java
+vacuumActions.playSound("beep")
+```
+
+Supported sound types include:
+
+- `beep`
+- `iAmHere`
+- `startup`
+- `suspended`
+- `batteryLow`
+
+For special use cases, there is also a `playSoundWithId(int soundId)` method, where you can pass the numeric ID of the sound to play.
+The exact meaning of the number depends on the specific device; you'll need to experiment with different numbers to see how the number-to-sound mapping looks like.
+For reference, a list for the Deebot 900 can be found [here](https://github.com/bmartin5692/sucks/blob/D901/protocol.md#user-content-sounds).
+
+## File Based Configuration
+
+If you want to create the API bridge in a .things file, the entry has to look as follows:
+
+```java
+Bridge ecovacs:ecovacsapi:ecovacsapi [ email="your.email@provider.com", password="yourpassword", continent="ww" ]
+```
+
+The possible values for `continent` include the following values:
+
+- `ww` for World
+- `eu` for Europe
+- `na` for North America
+- `as` for Asia
+
+The devices are detected automatically.
+If you also want to enter those manually, the syntax is as follows:
+
+```java
+Bridge ecovacs:ecovacsapi:ecovacsapi [ email="your.email@provider.com", password="yourpassword", continent="ww" ]
+{
+ Thing vacuum myDeebot "Deebot Vacuum" [ serialNumber="serial as printed on label below dust bin" ]
+}
+```
+
diff --git a/bundles/org.openhab.binding.ecovacs/pom.xml b/bundles/org.openhab.binding.ecovacs/pom.xml
new file mode 100644
index 0000000000000..fe9554e77841c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.0.0-SNAPSHOT
+
+
+ org.openhab.binding.ecovacs
+
+ openHAB Add-ons :: Bundles :: Ecovacs Binding
+
+ 4.3.3
+
+
+
+
+ org.igniterealtime.smack
+ smack-tcp
+ ${smack.version}
+ provided
+
+
+ org.igniterealtime.smack
+ smack-im
+ ${smack.version}
+ provided
+
+
+ org.igniterealtime.smack
+ smack-extensions
+ ${smack.version}
+ provided
+
+
+ org.igniterealtime.smack
+ smack-java7
+ ${smack.version}
+ provided
+
+
+ org.igniterealtime.smack
+ smack-resolver-javax
+ ${smack.version}
+
+
+
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/feature/feature.xml b/bundles/org.openhab.binding.ecovacs/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..ee9c3121e6334
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/feature/feature.xml
@@ -0,0 +1,22 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ openhab.tp-hivemqclient
+ mvn:org.igniterealtime.smack/smack-tcp/4.3.3
+ mvn:org.jxmpp/jxmpp-core/0.6.3
+ mvn:org.jxmpp/jxmpp-jid/0.6.3
+ mvn:org.jxmpp/jxmpp-util-cache/0.6.3
+ mvn:org.minidns/minidns-core/0.3.3
+ mvn:org.igniterealtime.smack/smack-core/4.3.3
+ mvn:org.igniterealtime.smack/smack-im/4.3.3
+ mvn:org.igniterealtime.smack/smack-extensions/4.3.3
+ mvn:org.apache.servicemix.bundles/org.apache.servicemix.bundles.xpp3/1.1.4c_7
+ mvn:org.igniterealtime.smack/smack-resolver-javax/4.3.3
+ mvn:org.igniterealtime.smack/smack-java7/4.3.3
+ mvn:org.igniterealtime.smack/smack-sasl-javax/4.3.3
+ mvn:org.openhab.addons.bundles/org.openhab.binding.ecovacs/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsBindingConstants.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsBindingConstants.java
new file mode 100644
index 0000000000000..a805ea83a67ea
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsBindingConstants.java
@@ -0,0 +1,120 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.commands.PlaySoundCommand.SoundType;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
+import org.openhab.binding.ecovacs.internal.api.model.MoppingWaterAmount;
+import org.openhab.binding.ecovacs.internal.api.model.SuctionPower;
+import org.openhab.binding.ecovacs.internal.util.StateOptionEntry;
+import org.openhab.binding.ecovacs.internal.util.StateOptionMapping;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link EcovacsBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsBindingConstants {
+ private static final String BINDING_ID = "ecovacs";
+
+ // Client keys and secrets used for API authentication (extracted from Ecovacs app)
+ public static final String CLIENT_KEY = "1520391301804";
+ public static final String CLIENT_SECRET = "6c319b2a5cd3e66e39159c2e28f2fce9";
+ public static final String AUTH_CLIENT_KEY = "1520391491841";
+ public static final String AUTH_CLIENT_SECRET = "77ef58ce3afbe337da74aa8c5ab963a9";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "ecovacsapi");
+ public static final ThingTypeUID THING_TYPE_VACUUM = new ThingTypeUID(BINDING_ID, "vacuum");
+
+ // List of all channel UIDs
+ public static final String CHANNEL_ID_AUTO_EMPTY = "settings#auto-empty";
+ public static final String CHANNEL_ID_BATTERY_LEVEL = "status#battery";
+ public static final String CHANNEL_ID_CLEANING_MODE = "status#current-cleaning-mode";
+ public static final String CHANNEL_ID_CLEANING_TIME = "status#current-cleaning-time";
+ public static final String CHANNEL_ID_CLEANED_AREA = "status#current-cleaned-area";
+ public static final String CHANNEL_ID_CLEANING_PASSES = "settings#cleaning-passes";
+ public static final String CHANNEL_ID_CLEANING_SPOT_DEFINITION = "status#current-cleaning-spot-definition";
+ public static final String CHANNEL_ID_CONTINUOUS_CLEANING = "settings#continuous-cleaning";
+ public static final String CHANNEL_ID_COMMAND = "actions#command";
+ public static final String CHANNEL_ID_DUST_FILTER_LIFETIME = "consumables#dust-filter-lifetime";
+ public static final String CHANNEL_ID_ERROR_CODE = "status#error-code";
+ public static final String CHANNEL_ID_ERROR_DESCRIPTION = "status#error-description";
+ public static final String CHANNEL_ID_LAST_CLEAN_START = "last-clean#last-clean-start";
+ public static final String CHANNEL_ID_LAST_CLEAN_DURATION = "last-clean#last-clean-duration";
+ public static final String CHANNEL_ID_LAST_CLEAN_AREA = "last-clean#last-clean-area";
+ public static final String CHANNEL_ID_LAST_CLEAN_MODE = "last-clean#last-clean-mode";
+ public static final String CHANNEL_ID_LAST_CLEAN_MAP = "last-clean#last-clean-map";
+ public static final String CHANNEL_ID_MAIN_BRUSH_LIFETIME = "consumables#main-brush-lifetime";
+ public static final String CHANNEL_ID_OTHER_COMPONENT_LIFETIME = "consumables#other-component-lifetime";
+ public static final String CHANNEL_ID_SIDE_BRUSH_LIFETIME = "consumables#side-brush-lifetime";
+ public static final String CHANNEL_ID_STATE = "status#state";
+ public static final String CHANNEL_ID_SUCTION_POWER = "settings#suction-power";
+ public static final String CHANNEL_ID_TOTAL_CLEANING_TIME = "total-stats#total-cleaning-time";
+ public static final String CHANNEL_ID_TOTAL_CLEANED_AREA = "total-stats#total-cleaned-area";
+ public static final String CHANNEL_ID_TOTAL_CLEAN_RUNS = "total-stats#total-clean-runs";
+ public static final String CHANNEL_ID_TRUE_DETECT_3D = "settings#true-detect-3d";
+ 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_WIFI_RSSI = "status#wifi-rssi";
+
+ public static final String CHANNEL_TYPE_ID_CLEAN_MODE = "current-cleaning-mode";
+ public static final String CHANNEL_TYPE_ID_LAST_CLEAN_MODE = "last-clean-mode";
+
+ public static final String CMD_AUTO_CLEAN = "clean";
+ public static final String CMD_PAUSE = "pause";
+ public static final String CMD_RESUME = "resume";
+ public static final String CMD_CHARGE = "charge";
+ public static final String CMD_STOP = "stop";
+ public static final String CMD_SPOT_AREA = "spotArea";
+ public static final String CMD_CUSTOM_AREA = "customArea";
+
+ public static final StateOptionMapping CLEAN_MODE_MAPPING = StateOptionMapping. of(
+ new StateOptionEntry(CleanMode.AUTO, "auto"),
+ new StateOptionEntry(CleanMode.EDGE, "edge", DeviceCapability.EDGE_CLEANING),
+ new StateOptionEntry(CleanMode.SPOT, "spot", DeviceCapability.SPOT_CLEANING),
+ new StateOptionEntry(CleanMode.SPOT_AREA, "spotArea", DeviceCapability.SPOT_AREA_CLEANING),
+ new StateOptionEntry(CleanMode.CUSTOM_AREA, "customArea", DeviceCapability.CUSTOM_AREA_CLEANING),
+ new StateOptionEntry(CleanMode.SINGLE_ROOM, "singleRoom", DeviceCapability.SINGLE_ROOM_CLEANING),
+ new StateOptionEntry(CleanMode.PAUSE, "pause"),
+ new StateOptionEntry(CleanMode.STOP, "stop"),
+ new StateOptionEntry(CleanMode.WASHING, "washing"),
+ new StateOptionEntry(CleanMode.DRYING, "drying"),
+ new StateOptionEntry(CleanMode.RETURNING, "returning"));
+
+ public static final StateOptionMapping WATER_AMOUNT_MAPPING = StateOptionMapping
+ . of(new StateOptionEntry(MoppingWaterAmount.LOW, "low"),
+ new StateOptionEntry(MoppingWaterAmount.MEDIUM, "medium"),
+ new StateOptionEntry(MoppingWaterAmount.HIGH, "high"),
+ new StateOptionEntry(MoppingWaterAmount.VERY_HIGH, "veryhigh"));
+
+ public static final StateOptionMapping SUCTION_POWER_MAPPING = StateOptionMapping. of(
+ new StateOptionEntry(SuctionPower.SILENT, "silent",
+ DeviceCapability.EXTENDED_CLEAN_SPEED_CONTROL),
+ new StateOptionEntry(SuctionPower.NORMAL, "normal"),
+ new StateOptionEntry(SuctionPower.HIGH, "high"), new StateOptionEntry(
+ SuctionPower.HIGHER, "higher", DeviceCapability.EXTENDED_CLEAN_SPEED_CONTROL));
+
+ public static final StateOptionMapping SOUND_TYPE_MAPPING = StateOptionMapping. of(
+ new StateOptionEntry(SoundType.BEEP, "beep"),
+ new StateOptionEntry(SoundType.I_AM_HERE, "iAmHere"),
+ new StateOptionEntry(SoundType.STARTUP, "startup"),
+ new StateOptionEntry(SoundType.SUSPENDED, "suspended"),
+ new StateOptionEntry(SoundType.BATTERY_LOW, "batteryLow"));
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsDynamicStateDescriptionProvider.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsDynamicStateDescriptionProvider.java
new file mode 100644
index 0000000000000..a7a1190dcea52
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsDynamicStateDescriptionProvider.java
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.binding.BaseDynamicStateDescriptionProvider;
+import org.openhab.core.thing.i18n.ChannelTypeI18nLocalizationService;
+import org.openhab.core.thing.link.ItemChannelLinkRegistry;
+import org.openhab.core.thing.type.ChannelTypeUID;
+import org.openhab.core.thing.type.DynamicStateDescriptionProvider;
+import org.openhab.core.types.StateOption;
+import org.osgi.framework.Bundle;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = { DynamicStateDescriptionProvider.class, EcovacsDynamicStateDescriptionProvider.class })
+public class EcovacsDynamicStateDescriptionProvider extends BaseDynamicStateDescriptionProvider {
+ private final TranslationProvider i18nProvider;
+
+ @Activate
+ public EcovacsDynamicStateDescriptionProvider(final @Reference EventPublisher eventPublisher,
+ final @Reference TranslationProvider i18nProvider,
+ final @Reference ItemChannelLinkRegistry itemChannelLinkRegistry,
+ final @Reference ChannelTypeI18nLocalizationService channelTypeI18nLocalizationService) {
+ this.eventPublisher = eventPublisher;
+ this.i18nProvider = i18nProvider;
+ this.itemChannelLinkRegistry = itemChannelLinkRegistry;
+ this.channelTypeI18nLocalizationService = channelTypeI18nLocalizationService;
+ }
+
+ @Override
+ protected List localizedStateOptions(List options, Channel channel,
+ @Nullable Locale locale) {
+ @Nullable
+ ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
+ String channelTypeId = channelTypeUID != null ? channelTypeUID.getId() : "";
+ if (CHANNEL_TYPE_ID_CLEAN_MODE.equals(channelTypeId) || CHANNEL_TYPE_ID_LAST_CLEAN_MODE.equals(channelTypeId)) {
+ final Bundle bundle = bundleContext.getBundle();
+ return options.stream().map(opt -> {
+ String key = "ecovacs.cleaning-mode." + opt.getValue();
+ String label = this.i18nProvider.getText(bundle, key, opt.getLabel(), locale);
+ return new StateOption(opt.getValue(), label);
+ }).collect(Collectors.toList());
+ }
+ return super.localizedStateOptions(options, channel, locale);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsHandlerFactory.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsHandlerFactory.java
new file mode 100644
index 0000000000000..dee2533f7599f
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsHandlerFactory.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.handler.EcovacsApiHandler;
+import org.openhab.binding.ecovacs.internal.handler.EcovacsVacuumHandler;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link EcovacsHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.ecovacs", service = ThingHandlerFactory.class)
+public class EcovacsHandlerFactory extends BaseThingHandlerFactory {
+ private final HttpClientFactory httpClientFactory;
+ private final LocaleProvider localeProvider;
+ private final TranslationProvider i18Provider;
+ private final EcovacsDynamicStateDescriptionProvider stateDescriptionProvider;
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_API, THING_TYPE_VACUUM);
+
+ @Activate
+ public EcovacsHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
+ final @Reference EcovacsDynamicStateDescriptionProvider stateDescriptionProvider,
+ final @Reference LocaleProvider localeProvider, final @Reference TranslationProvider i18Provider) {
+ this.httpClientFactory = httpClientFactory;
+ this.stateDescriptionProvider = stateDescriptionProvider;
+ this.localeProvider = localeProvider;
+ this.i18Provider = i18Provider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_API.equals(thingTypeUID)) {
+ return new EcovacsApiHandler((Bridge) thing, httpClientFactory.getCommonHttpClient(), localeProvider);
+ } else {
+ return new EcovacsVacuumHandler(thing, i18Provider, localeProvider, stateDescriptionProvider);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/action/EcovacsVacuumActions.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/action/EcovacsVacuumActions.java
new file mode 100644
index 0000000000000..1d0a941662ef9
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/action/EcovacsVacuumActions.java
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2023 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.action;
+
+import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.commands.PlaySoundCommand;
+import org.openhab.binding.ecovacs.internal.handler.EcovacsVacuumHandler;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@ThingActionsScope(name = "ecovacs")
+@NonNullByDefault
+public class EcovacsVacuumActions implements ThingActions {
+ private final Logger logger = LoggerFactory.getLogger(EcovacsVacuumActions.class);
+ private @Nullable EcovacsVacuumHandler handler;
+
+ @Override
+ public void setThingHandler(@Nullable ThingHandler handler) {
+ this.handler = (EcovacsVacuumHandler) handler;
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return handler;
+ }
+
+ @RuleAction(label = "@text/playSoundActionLabel", description = "@text/playSoundActionDesc")
+ public void playSound(
+ @ActionInput(name = "type", label = "@text/actionInputSoundTypeLabel", description = "@text/actionInputSoundTypeDesc") String type) {
+ EcovacsVacuumHandler handler = this.handler;
+ if (handler != null) {
+ Optional soundType = SOUND_TYPE_MAPPING.findMappedEnumValue(type);
+ if (soundType.isPresent()) {
+ handler.playSound(new PlaySoundCommand(soundType.get()));
+ } else {
+ logger.debug("Sound type '{}' is unknown, ignoring", type);
+ }
+ }
+ }
+
+ @RuleAction(label = "@text/playSoundActionLabel", description = "@text/playSoundActionDesc")
+ public void playSoundWithId(
+ @ActionInput(name = "soundId", label = "@text/actionInputSoundIdLabel", description = "@text/actionInputSoundIdDesc") int soundId) {
+ EcovacsVacuumHandler handler = this.handler;
+ if (handler != null) {
+ handler.playSound(new PlaySoundCommand(soundId));
+ }
+ }
+
+ public static void playSound(@Nullable ThingActions actions, String type) {
+ if (actions instanceof EcovacsVacuumActions) {
+ ((EcovacsVacuumActions) actions).playSound(type);
+ }
+ }
+
+ public static void playSoundWithId(@Nullable ThingActions actions, int soundId) {
+ if (actions instanceof EcovacsVacuumActions) {
+ ((EcovacsVacuumActions) actions).playSoundWithId(soundId);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApi.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApi.java
new file mode 100644
index 0000000000000..b226bb636124c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApi.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.ecovacs.internal.api.impl.EcovacsApiImpl;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public interface EcovacsApi {
+ public static EcovacsApi create(HttpClient httpClient, EcovacsApiConfiguration configuration) {
+ return new EcovacsApiImpl(httpClient, configuration);
+ }
+
+ public void loginAndGetAccessToken() throws EcovacsApiException, InterruptedException;
+
+ public List getDevices() throws EcovacsApiException, InterruptedException;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiConfiguration.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiConfiguration.java
new file mode 100644
index 0000000000000..9019c6804eed4
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiConfiguration.java
@@ -0,0 +1,140 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public final class EcovacsApiConfiguration {
+ private final String deviceId;
+ private final String username;
+ private final String password;
+ private final String continent;
+ private final String country;
+ private final String language;
+ private final String clientKey;
+ private final String clientSecret;
+ private final String authClientKey;
+ private final String authClientSecret;
+
+ 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);
+ this.username = username;
+ this.password = password;
+ this.continent = continent;
+ this.country = country;
+ this.language = language;
+ this.clientKey = clientKey;
+ this.clientSecret = clientSecret;
+ this.authClientKey = authClientKey;
+ this.authClientSecret = authClientSecret;
+ }
+
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public String getContinent() {
+ return continent;
+ }
+
+ public String getCountry() {
+ if ("gb".equalsIgnoreCase(country)) {
+ // United Kingdom's ISO 3166 abbreviation is 'gb', but Ecovacs wants the TLD instead, which is 'uk' for
+ // historical reasons
+ return "uk";
+ }
+ return country.toLowerCase();
+ }
+
+ public String getLanguage() {
+ return language;
+ }
+
+ public String getResource() {
+ return deviceId.substring(0, 8);
+ }
+
+ public String getAuthOpenId() {
+ return "global";
+ }
+
+ public String getTimeZone() {
+ return "GMT-8";
+ }
+
+ public String getRealm() {
+ return "ecouser.net";
+ }
+
+ public String getPortalAUthRequestWith() {
+ return "users";
+ }
+
+ public String getOrg() {
+ return "ECOWW";
+ }
+
+ public String getEdition() {
+ return "ECOGLOBLE";
+ }
+
+ public String getBizType() {
+ return "ECOVACS_IOT";
+ }
+
+ public String getChannel() {
+ return "google_play";
+ }
+
+ public String getAppCode() {
+ return "global_e";
+ }
+
+ public String getAppVersion() {
+ return "1.6.3";
+ }
+
+ public String getDeviceType() {
+ return "1";
+ }
+
+ public String getClientKey() {
+ return clientKey;
+ }
+
+ public String getClientSecret() {
+ return clientSecret;
+ }
+
+ public String getAuthClientKey() {
+ return authClientKey;
+ }
+
+ public String getAuthClientSecret() {
+ return authClientSecret;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiException.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiException.java
new file mode 100644
index 0000000000000..aaf8cf2d35bd3
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiException.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.api.Response;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsApiException extends Exception {
+ private static final long serialVersionUID = -5903398729974682356L;
+ public final boolean isAuthFailure;
+
+ public EcovacsApiException(String reason) {
+ this(reason, false);
+ }
+
+ public EcovacsApiException(String reason, boolean isAuthFailure) {
+ super(reason);
+ this.isAuthFailure = isAuthFailure;
+ }
+
+ public EcovacsApiException(Response response) {
+ super("HTTP status " + response.getStatus());
+ isAuthFailure = response.getStatus() == 401;
+ }
+
+ public EcovacsApiException(Throwable cause) {
+ this(cause, false);
+ }
+
+ public EcovacsApiException(Throwable cause, boolean isAuthFailure) {
+ super(cause);
+ this.isAuthFailure = isAuthFailure;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsDevice.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsDevice.java
new file mode 100644
index 0000000000000..e74e3f4964573
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsDevice.java
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
+import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public interface EcovacsDevice {
+ public interface EventListener {
+ void onFirmwareVersionChanged(EcovacsDevice device, String fwVersion);
+
+ void onBatteryLevelUpdated(EcovacsDevice device, int newLevelPercent);
+
+ void onChargingStateUpdated(EcovacsDevice device, boolean charging);
+
+ void onCleaningModeUpdated(EcovacsDevice device, CleanMode newMode, Optional areaDefinition);
+
+ void onCleaningStatsUpdated(EcovacsDevice device, int cleanedArea, int cleaningTimeSeconds);
+
+ void onWaterSystemPresentUpdated(EcovacsDevice device, boolean present);
+
+ void onErrorReported(EcovacsDevice device, int errorCode);
+
+ void onEventStreamFailure(EcovacsDevice device, Throwable error);
+ }
+
+ String getSerialNumber();
+
+ String getModelName();
+
+ boolean hasCapability(DeviceCapability cap);
+
+ void connect(EventListener listener, ScheduledExecutorService scheduler)
+ throws EcovacsApiException, InterruptedException;
+
+ void disconnect(ScheduledExecutorService scheduler);
+
+ T sendCommand(IotDeviceCommand command) throws EcovacsApiException, InterruptedException;
+
+ List getCleanLogs() throws EcovacsApiException, InterruptedException;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractAreaCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractAreaCleaningCommand.java
new file mode 100644
index 0000000000000..fb319d711c021
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractAreaCleaningCommand.java
@@ -0,0 +1,83 @@
+/**
+ * Copyright (c) 2010-2023 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 org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+class AbstractAreaCleaningCommand extends AbstractNoResponseCommand {
+ private final String jsonTypeName;
+ private final String areaDefinition;
+ private final int cleanPasses;
+
+ AbstractAreaCleaningCommand(String jsonTypeName, String areaDefinition, int cleanPasses) {
+ this.jsonTypeName = jsonTypeName;
+ this.areaDefinition = areaDefinition;
+ this.cleanPasses = cleanPasses;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ switch (version) {
+ case XML:
+ return "Clean";
+ case JSON:
+ return "clean";
+ case JSON_V2:
+ return "clean_V2";
+ }
+ throw new AssertionError();
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ Element clean = doc.createElement("clean");
+ clean.setAttribute("act", "s");
+ clean.setAttribute("type", "SpotArea");
+ clean.setAttribute("speed", "standard");
+ clean.setAttribute("p", areaDefinition);
+ clean.setAttribute("deep", String.valueOf(cleanPasses));
+ ctl.appendChild(clean);
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("act", "start");
+
+ JsonObject payload = args;
+ if (version == ProtocolVersion.JSON_V2) {
+ JsonObject content = new JsonObject();
+ args.add("content", content);
+ payload = content;
+ payload.addProperty("value", this.areaDefinition);
+ payload.addProperty("donotClean", 0);
+ payload.addProperty("total", 0);
+ } else {
+ payload.addProperty("content", this.areaDefinition);
+ }
+ payload.addProperty("count", cleanPasses);
+ payload.addProperty("type", this.jsonTypeName);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractCleaningCommand.java
new file mode 100644
index 0000000000000..aa796bec53071
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractCleaningCommand.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.impl.ProtocolVersion;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+abstract class AbstractCleaningCommand extends AbstractNoResponseCommand {
+ private final String xmlAction;
+ private final String jsonAction;
+ private final Optional mode;
+
+ protected AbstractCleaningCommand(String xmlAction, String jsonAction, @Nullable CleanMode mode) {
+ super();
+ this.xmlAction = xmlAction;
+ this.jsonAction = jsonAction;
+ this.mode = Optional.ofNullable(mode);
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ switch (version) {
+ case XML:
+ return "Clean";
+ case JSON:
+ return "clean";
+ case JSON_V2:
+ return "clean_V2";
+ }
+ throw new AssertionError();
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ Element clean = doc.createElement("clean");
+ getCleanModeProperty(ProtocolVersion.XML).ifPresent(m -> clean.setAttribute("type", m));
+ clean.setAttribute("speed", "standard");
+ clean.setAttribute("act", xmlAction);
+ ctl.appendChild(clean);
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("act", jsonAction);
+ getCleanModeProperty(version).ifPresent(m -> {
+ JsonObject payload = args;
+ if (version == ProtocolVersion.JSON_V2) {
+ JsonObject content = new JsonObject();
+ args.add("content", content);
+ payload = content;
+ }
+ payload.addProperty("type", m);
+ });
+ return args;
+ }
+
+ private Optional getCleanModeProperty(ProtocolVersion version) {
+ return mode.flatMap(m -> {
+ switch (m) {
+ case AUTO:
+ return Optional.of("auto");
+ case CUSTOM_AREA:
+ return Optional.of(version == ProtocolVersion.XML ? "CustomArea" : "customArea");
+ case EDGE:
+ return Optional.of("border");
+ case SPOT:
+ return Optional.of("spot");
+ case SPOT_AREA:
+ return Optional.of(version == ProtocolVersion.XML ? "SpotArea" : "spotArea");
+ case SINGLE_ROOM:
+ return Optional.of("singleRoom");
+ case STOP:
+ return Optional.of("stop");
+ default:
+ return Optional.empty();
+ }
+ });
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractNoResponseCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractNoResponseCommand.java
new file mode 100644
index 0000000000000..d0f6ffff36d3a
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractNoResponseCommand.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2023 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.portal.AbstractPortalIotCommandResponse;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public abstract class AbstractNoResponseCommand extends IotDeviceCommand {
+ public static class Nothing {
+ private Nothing() {
+ }
+
+ private static final Nothing INSTANCE = new Nothing();
+ }
+
+ protected AbstractNoResponseCommand() {
+ super();
+ }
+
+ @Override
+ public Nothing convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson) {
+ return Nothing.INSTANCE;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/CustomAreaCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/CustomAreaCleaningCommand.java
new file mode 100644
index 0000000000000..bb548091a9a74
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/CustomAreaCleaningCommand.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class CustomAreaCleaningCommand extends AbstractAreaCleaningCommand {
+ public CustomAreaCleaningCommand(String areaDefinition, int cleanPasses) {
+ super("customArea", areaDefinition, cleanPasses);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/EmptyDustbinCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/EmptyDustbinCommand.java
new file mode 100644
index 0000000000000..6e097d7683bde
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/EmptyDustbinCommand.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2023 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 EmptyDustbinCommand extends AbstractNoResponseCommand {
+ public EmptyDustbinCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Empty dust bin is not supported for XML");
+ }
+ return "setAutoEmpty";
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("act", "start");
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetActiveMapIdCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetActiveMapIdCommand.java
new file mode 100644
index 0000000000000..943846afdc129
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetActiveMapIdCommand.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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.CachedMapInfoReport;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetActiveMapIdCommand extends IotDeviceCommand {
+ public GetActiveMapIdCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetMapM" : "getCachedMapInfo";
+ }
+
+ @Override
+ public String convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ CachedMapInfoReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ CachedMapInfoReport.class);
+ return resp.mapInfos.stream().filter(i -> i.used != 0).map(i -> i.mapId).findFirst().orElse("");
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return XPathUtils.getFirstXPathMatch(payload, "//@i").getNodeValue();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetBatteryInfoCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetBatteryInfoCommand.java
new file mode 100644
index 0000000000000..9b5b0cdcf42f0
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetBatteryInfoCommand.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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.BatteryReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.DeviceInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetBatteryInfoCommand extends IotDeviceCommand {
+ public GetBatteryInfoCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetBatteryInfo" : "getBattery";
+ }
+
+ @Override
+ public Integer convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ BatteryReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ BatteryReport.class);
+ return resp.percent;
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return DeviceInfo.parseBatteryInfo(payload);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetChargeStateCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetChargeStateCommand.java
new file mode 100644
index 0000000000000..ebf79af19d02c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetChargeStateCommand.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2023 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.ChargeReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.DeviceInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.ChargeMode;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetChargeStateCommand extends IotDeviceCommand {
+ public GetChargeStateCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetChargeState" : "getChargeState";
+ }
+
+ @Override
+ public ChargeMode convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ ChargeReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ ChargeReport.class);
+ return resp.isCharging != 0 ? ChargeMode.CHARGING : ChargeMode.IDLE;
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return DeviceInfo.parseChargeInfo(payload, gson);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanLogsCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanLogsCommand.java
new file mode 100644
index 0000000000000..5d2f419d472e6
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanLogsCommand.java
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) 2010-2023 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 java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+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.portal.AbstractPortalIotCommandResponse;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetCleanLogsCommand extends IotDeviceCommand> {
+ private static final int LOG_SIZE = 20;
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version != ProtocolVersion.XML) {
+ throw new IllegalStateException("Command is only supported for XML");
+ }
+ return "GetCleanLogs";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("count", String.valueOf(LOG_SIZE));
+ }
+
+ @Override
+ public List convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version,
+ Gson gson) throws DataParsingException {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ try {
+ DocumentBuilder db = dbf.newDocumentBuilder();
+ NodeList entryNodes = db.parse(new ByteArrayInputStream(payload.getBytes("UTF-8"))).getFirstChild()
+ .getChildNodes();
+ List result = new ArrayList<>();
+
+ for (int i = 0; i < entryNodes.getLength(); i++) {
+ NamedNodeMap attrs = entryNodes.item(i).getAttributes();
+ String area = attrs.getNamedItem("a").getNodeValue();
+ String startTime = attrs.getNamedItem("s").getNodeValue();
+ String duration = attrs.getNamedItem("l").getNodeValue();
+
+ result.add(new CleanLogRecord(Long.parseLong(startTime), Integer.parseInt(duration),
+ Integer.parseInt(area), Optional.empty(), CleanMode.IDLE));
+ }
+ return result;
+ } catch (ParserConfigurationException | SAXException | IOException e) {
+ throw new DataParsingException(e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanStateCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanStateCommand.java
new file mode 100644
index 0000000000000..66b23e65238ef
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanStateCommand.java
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2023 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.CleanReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.CleanReportV2;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.CleaningInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetCleanStateCommand extends IotDeviceCommand {
+ public GetCleanStateCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ switch (version) {
+ case XML:
+ return "GetCleanState";
+ case JSON:
+ return "getCleanInfo";
+ case JSON_V2:
+ return "getCleanInfo_V2";
+ }
+ throw new AssertionError();
+ }
+
+ @Override
+ public CleanMode convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ final PortalIotCommandJsonResponse jsonResponse = (PortalIotCommandJsonResponse) response;
+ final CleanMode mode;
+ if (version == ProtocolVersion.JSON) {
+ CleanReport resp = jsonResponse.getResponsePayloadAs(gson, CleanReport.class);
+ mode = resp.determineCleanMode(gson);
+ } else {
+ CleanReportV2 resp = jsonResponse.getResponsePayloadAs(gson, CleanReportV2.class);
+ mode = resp.determineCleanMode(gson);
+ }
+ if (mode == null) {
+ throw new DataParsingException("Could not get clean mode from response " + jsonResponse.response);
+ }
+ return mode;
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return CleaningInfo.parseCleanStateInfo(payload, gson).mode;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetComponentLifeSpanCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetComponentLifeSpanCommand.java
new file mode 100644
index 0000000000000..92dbaed614635
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetComponentLifeSpanCommand.java
@@ -0,0 +1,86 @@
+/**
+ * Copyright (c) 2010-2023 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 java.lang.reflect.Type;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.impl.ProtocolVersion;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.ComponentLifeSpanReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.DeviceInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.Component;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetComponentLifeSpanCommand extends IotDeviceCommand {
+ private final Component type;
+
+ public GetComponentLifeSpanCommand(Component type) {
+ this.type = type;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetLifeSpan" : "getLifeSpan";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("type", type.xmlValue);
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonArray args = new JsonArray(1);
+ args.add(type.jsonValue);
+ return args;
+ }
+
+ @Override
+ public Integer convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ JsonElement respPayloadRaw = ((PortalIotCommandJsonResponse) response).getResponsePayload(gson);
+ Type type = new TypeToken>() {
+ }.getType();
+ try {
+ List resp = gson.fromJson(respPayloadRaw, type);
+ if (resp == null || resp.isEmpty()) {
+ throw new DataParsingException("Invalid lifespan response " + respPayloadRaw);
+ }
+ return (int) Math.round(100.0 * resp.get(0).left / resp.get(0).total);
+ } catch (JsonSyntaxException e) {
+ throw new DataParsingException(e);
+ }
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return DeviceInfo.parseComponentLifespanInfo(payload);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetContinuousCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetContinuousCleaningCommand.java
new file mode 100644
index 0000000000000..7c12ccee26d8c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetContinuousCleaningCommand.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2023 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.EnabledStateReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.DeviceInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetContinuousCleaningCommand extends IotDeviceCommand {
+ public GetContinuousCleaningCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetOnOff" : "getBreakPoint";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("t", "g");
+ }
+
+ @Override
+ public Boolean convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ EnabledStateReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ EnabledStateReport.class);
+ return resp.enabled != 0;
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return DeviceInfo.parseEnabledStateInfo(payload);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDefaultCleanPassesCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDefaultCleanPassesCommand.java
new file mode 100644
index 0000000000000..1de42221a4bdd
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDefaultCleanPassesCommand.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2023 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.DefaultCleanCountReport;
+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 GetDefaultCleanPassesCommand extends IotDeviceCommand {
+ public GetDefaultCleanPassesCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Command is not supported for XML");
+ }
+ return "getCleanCount";
+ }
+
+ @Override
+ public Integer convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ DefaultCleanCountReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ DefaultCleanCountReport.class);
+ return resp.count;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDustbinAutoEmptyCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDustbinAutoEmptyCommand.java
new file mode 100644
index 0000000000000..e44f844382d62
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDustbinAutoEmptyCommand.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2023 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.EnabledStateReport;
+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 GetDustbinAutoEmptyCommand extends IotDeviceCommand {
+ public GetDustbinAutoEmptyCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Command is not supported for XML");
+ }
+ return "getAutoEmpty";
+ }
+
+ @Override
+ public Boolean convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ EnabledStateReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ EnabledStateReport.class);
+ return resp.enabled != 0;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetErrorCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetErrorCommand.java
new file mode 100644
index 0000000000000..fac4743c9bcfc
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetErrorCommand.java
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.Optional;
+
+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.ErrorReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.DeviceInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetErrorCommand extends IotDeviceCommand> {
+ public GetErrorCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetError" : "getError";
+ }
+
+ @Override
+ public Optional convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version,
+ Gson gson) throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ ErrorReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson, ErrorReport.class);
+ if (resp.errorCodes.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(resp.errorCodes.get(0));
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return DeviceInfo.parseErrorInfo(payload);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetFirmwareVersionCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetFirmwareVersionCommand.java
new file mode 100644
index 0000000000000..375eeb860a761
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetFirmwareVersionCommand.java
@@ -0,0 +1,54 @@
+/**
+ * Copyright (c) 2010-2023 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.portal.AbstractPortalIotCommandResponse;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetFirmwareVersionCommand extends IotDeviceCommand {
+ public GetFirmwareVersionCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version != ProtocolVersion.XML) {
+ throw new IllegalStateException("Get FW version is only supported for XML");
+ }
+ return "GetVersion";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("name", "FW");
+ }
+
+ @Override
+ public String convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return XPathUtils.getFirstXPathMatch(payload, "//ver[@name='FW']").getTextContent();
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMapSpotAreasWithMapIdCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMapSpotAreasWithMapIdCommand.java
new file mode 100644
index 0000000000000..496fbb49fa8fb
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMapSpotAreasWithMapIdCommand.java
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.impl.ProtocolVersion;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.MapSetReport;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetMapSpotAreasWithMapIdCommand extends IotDeviceCommand> {
+ private final String mapId;
+
+ public GetMapSpotAreasWithMapIdCommand(String mapId) {
+ this.mapId = mapId;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetMapSet" : "getMapSet";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("tp", "sa");
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("mid", mapId);
+ args.addProperty("type", "ar");
+ return args;
+ }
+
+ @Override
+ public List convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ MapSetReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ MapSetReport.class);
+ return resp.subsets.stream().map(i -> i.id).collect(Collectors.toList());
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ NodeList mapIds = XPathUtils.getXPathMatches(payload, "//m/@mid");
+ List result = new ArrayList<>();
+ for (int i = 0; i < mapIds.getLength(); i++) {
+ result.add(mapIds.item(i).getNodeValue());
+ }
+ return result;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMoppingWaterAmountCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMoppingWaterAmountCommand.java
new file mode 100644
index 0000000000000..e300e9879cf88
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMoppingWaterAmountCommand.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2023 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.deviceapi.xml.WaterSystemInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.MoppingWaterAmount;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetMoppingWaterAmountCommand extends IotDeviceCommand {
+ public GetMoppingWaterAmountCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetWaterPermeability" : "getWaterInfo";
+ }
+
+ @Override
+ public MoppingWaterAmount convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version,
+ Gson gson) throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ WaterInfoReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ WaterInfoReport.class);
+ return MoppingWaterAmount.fromApiValue(resp.waterAmount);
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return WaterSystemInfo.parseWaterPermeabilityInfo(payload);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetNetworkInfoCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetNetworkInfoCommand.java
new file mode 100644
index 0000000000000..4c23ca375dd3c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetNetworkInfoCommand.java
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2023 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.NetworkInfoReport;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.NetworkInfo;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.w3c.dom.Node;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetNetworkInfoCommand extends IotDeviceCommand {
+ public GetNetworkInfoCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetNetInfo" : "getNetInfo";
+ }
+
+ @Override
+ public NetworkInfo convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ NetworkInfoReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ NetworkInfoReport.class);
+ try {
+ return new NetworkInfo(resp.ip, resp.mac, resp.ssid, Integer.valueOf(resp.rssi));
+ } catch (NumberFormatException e) {
+ throw new DataParsingException(e);
+ }
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ Node ipAttr = XPathUtils.getFirstXPathMatch(payload, "//@wi");
+ Node ssidAttr = XPathUtils.getFirstXPathMatch(payload, "//@s");
+ return new NetworkInfo(ipAttr.getNodeValue(), "", ssidAttr.getNodeValue(), 0);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetSuctionPowerCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetSuctionPowerCommand.java
new file mode 100644
index 0000000000000..45b86d8ead647
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetSuctionPowerCommand.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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.SpeedReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.CleaningInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.model.SuctionPower;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetSuctionPowerCommand extends IotDeviceCommand {
+ public GetSuctionPowerCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetCleanSpeed" : "getSpeed";
+ }
+
+ @Override
+ public SuctionPower convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ SpeedReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson, SpeedReport.class);
+ return SuctionPower.fromJsonValue(resp.speedLevel);
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return CleaningInfo.parseCleanSpeedInfo(payload, gson);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTotalStatsCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTotalStatsCommand.java
new file mode 100644
index 0000000000000..04179c1c5425b
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTotalStatsCommand.java
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2023 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.portal.AbstractPortalIotCommandResponse;
+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.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetTotalStatsCommand extends IotDeviceCommand {
+ public class TotalStats {
+ @SerializedName("area")
+ public final int totalArea;
+ @SerializedName("time")
+ public final int totalRuntime;
+ @SerializedName("count")
+ public final int cleanRuns;
+
+ private TotalStats(int area, int runtime, int runs) {
+ this.totalArea = area;
+ this.totalRuntime = runtime;
+ this.cleanRuns = runs;
+ }
+ }
+
+ public GetTotalStatsCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetCleanSum" : "getTotalStats";
+ }
+
+ @Override
+ public TotalStats convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ return ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson, TotalStats.class);
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ String area = XPathUtils.getFirstXPathMatch(payload, "//@a").getNodeValue();
+ String time = XPathUtils.getFirstXPathMatch(payload, "//@l").getNodeValue();
+ String count = XPathUtils.getFirstXPathMatch(payload, "//@c").getNodeValue();
+ try {
+ return new TotalStats(Integer.valueOf(area), Integer.valueOf(time), Integer.valueOf(count));
+ } catch (NumberFormatException e) {
+ throw new DataParsingException(e);
+ }
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTrueDetectCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTrueDetectCommand.java
new file mode 100644
index 0000000000000..f3f5416e84b6e
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTrueDetectCommand.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2023 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.EnabledStateReport;
+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 GetTrueDetectCommand extends IotDeviceCommand {
+ public GetTrueDetectCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Command is not supported for XML");
+ }
+ return "getTrueDetect";
+ }
+
+ @Override
+ public Boolean convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ EnabledStateReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ EnabledStateReport.class);
+ return resp.enabled != 0;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetVolumeCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetVolumeCommand.java
new file mode 100644
index 0000000000000..fc75720425c54
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetVolumeCommand.java
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2010-2023 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.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;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetVolumeCommand extends IotDeviceCommand {
+ public GetVolumeCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Get volume command is not supported for XML");
+ }
+ return "getVolume";
+ }
+
+ @Override
+ public Integer convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ JsonResponse resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ JsonResponse.class);
+ return resp.volume;
+ } else {
+ // unsupported in XML case?
+ return 0;
+ }
+ }
+
+ private static class JsonResponse {
+ @SerializedName("volume")
+ public int volume;
+
+ @SerializedName("total")
+ public int maxVolume;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetWaterSystemPresentCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetWaterSystemPresentCommand.java
new file mode 100644
index 0000000000000..55e5d3265223f
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetWaterSystemPresentCommand.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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.deviceapi.xml.WaterSystemInfo;
+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.impl.dto.response.portal.PortalIotCommandXmlResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GetWaterSystemPresentCommand extends IotDeviceCommand {
+ public GetWaterSystemPresentCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "GetWaterBoxInfo" : "getWaterInfo";
+ }
+
+ @Override
+ public Boolean convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version, Gson gson)
+ throws DataParsingException {
+ if (response instanceof PortalIotCommandJsonResponse) {
+ WaterInfoReport resp = ((PortalIotCommandJsonResponse) response).getResponsePayloadAs(gson,
+ WaterInfoReport.class);
+ return resp.waterPlatePresent != 0;
+ } else {
+ String payload = ((PortalIotCommandXmlResponse) response).getResponsePayloadXml();
+ return WaterSystemInfo.parseWaterBoxInfo(payload);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GoChargingCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GoChargingCommand.java
new file mode 100644
index 0000000000000..0aac16d32bf07
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GoChargingCommand.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2023 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 org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class GoChargingCommand extends AbstractNoResponseCommand {
+ public GoChargingCommand() {
+ super();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "Charge" : "charge";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ Element charge = doc.createElement("charge");
+ charge.setAttribute("type", "go");
+ ctl.appendChild(charge);
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("act", "go");
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/IotDeviceCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/IotDeviceCommand.java
new file mode 100644
index 0000000000000..611b2bdd7a005
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/IotDeviceCommand.java
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) 2010-2023 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 java.io.StringWriter;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerException;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.impl.ProtocolVersion;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotCommandRequest.JsonPayloadHeader;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.AbstractPortalIotCommandResponse;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public abstract class IotDeviceCommand {
+ protected IotDeviceCommand() {
+ }
+
+ public abstract String getName(ProtocolVersion version);
+
+ public final String getXmlPayload(@Nullable String id) throws ParserConfigurationException, TransformerException {
+ Document xmlDoc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
+ Element ctl = xmlDoc.createElement("ctl");
+ ctl.setAttribute("td", getName(ProtocolVersion.XML));
+ if (id != null) {
+ ctl.setAttribute("id", id);
+ }
+ applyXmlPayload(xmlDoc, ctl);
+ xmlDoc.appendChild(ctl);
+ Transformer tf = TransformerFactory.newInstance().newTransformer();
+ tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
+ StringWriter writer = new StringWriter();
+ tf.transform(new DOMSource(xmlDoc), new StreamResult(writer));
+ return writer.getBuffer().toString().replaceAll("\n|\r", "");
+ }
+
+ public final JsonElement getJsonPayload(ProtocolVersion version, Gson gson) {
+ JsonObject result = new JsonObject();
+ result.add("header", gson.toJsonTree(new JsonPayloadHeader()));
+ @Nullable
+ JsonElement args = getJsonPayloadArgs(version);
+ if (args != null) {
+ JsonObject body = new JsonObject();
+ body.add("data", args);
+ result.add("body", body);
+ }
+ return result;
+ }
+
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ return null;
+ }
+
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ }
+
+ public abstract RESPONSETYPE convertResponse(AbstractPortalIotCommandResponse response, ProtocolVersion version,
+ Gson gson) throws DataParsingException;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PauseCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PauseCleaningCommand.java
new file mode 100644
index 0000000000000..898784a66624a
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PauseCleaningCommand.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.model.CleanMode;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class PauseCleaningCommand extends AbstractCleaningCommand {
+ public PauseCleaningCommand(CleanMode mode) {
+ super("p", "pause", mode);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PlaySoundCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PlaySoundCommand.java
new file mode 100644
index 0000000000000..36725293ca0c0
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PlaySoundCommand.java
@@ -0,0 +1,96 @@
+/**
+ * Copyright (c) 2010-2023 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 org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class PlaySoundCommand extends AbstractNoResponseCommand {
+ public enum SoundType {
+ STARTUP(0),
+ SUSPENDED(3),
+ CHECK_WHEELS(4),
+ HELP_ME_OUT(5),
+ INSTALL_DUST_BIN(6),
+ BEEP(17),
+ BATTERY_LOW(18),
+ POWER_ON_BEFORE_CHARGE(29),
+ I_AM_HERE(30),
+ PLEASE_CLEAN_BRUSH(31),
+ PLEASE_CLEAN_SENSORS(35),
+ BRUSH_IS_TANGLED(48),
+ RELOCATING(55),
+ UPGRADE_DONE(56),
+ RETURNING_TO_CHARGE(63),
+ CLEANING_PAUSED(65),
+ CONNECTED_IN_SETUP(69),
+ RESTORING_MAP(71),
+ BATTERY_LOW_RETURNING_TO_DOCK(73),
+ DIFFICULT_TO_LOCATE(74),
+ RESUMING_CLEANING(75),
+ UPGRADE_FAILED(76),
+ PLACE_ON_CHARGING_DOCK(77),
+ RESUME_CLEANING(79),
+ STARTING_CLEANING(80),
+ READY_FOR_MOPPING(84),
+ REMOVE_MOPPING_PLATE(85),
+ CLEANING_COMPLETE(86),
+ LDS_MALFUNCTION(89),
+ UPGRADING(90);
+
+ final int id;
+
+ private SoundType(int id) {
+ this.id = id;
+ }
+ }
+
+ private final int soundId;
+
+ public PlaySoundCommand(SoundType type) {
+ super();
+ this.soundId = type.id;
+ }
+
+ public PlaySoundCommand(int soundId) {
+ super();
+ this.soundId = soundId;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "PlaySound" : "playSound";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("sid", String.valueOf(soundId));
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("sid", soundId);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/ResumeCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/ResumeCleaningCommand.java
new file mode 100644
index 0000000000000..1ff6ddaa4ea3a
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/ResumeCleaningCommand.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.model.CleanMode;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class ResumeCleaningCommand extends AbstractCleaningCommand {
+ public ResumeCleaningCommand(CleanMode mode) {
+ super("r", "resume", mode);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetContinuousCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetContinuousCleaningCommand.java
new file mode 100644
index 0000000000000..edadfd8006043
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetContinuousCleaningCommand.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2023 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 org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class SetContinuousCleaningCommand extends AbstractNoResponseCommand {
+ private final boolean enabled;
+
+ public SetContinuousCleaningCommand(boolean enabled) {
+ super();
+ this.enabled = enabled;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "SetOnOff" : "setBreakPoint";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("t", "g");
+ ctl.setAttribute("on", enabled ? "1" : "0");
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("enable", enabled ? 1 : 0);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDefaultCleanPassesCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDefaultCleanPassesCommand.java
new file mode 100644
index 0000000000000..b58a3c80440a3
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDefaultCleanPassesCommand.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2010-2023 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 SetDefaultCleanPassesCommand extends AbstractNoResponseCommand {
+ private final int count;
+
+ public SetDefaultCleanPassesCommand(int count) {
+ if (count < 1 || count > 2) {
+ throw new IllegalArgumentException("Number of cleaning passes must be between 1 and 2");
+ }
+ this.count = count;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Set default clean count is not supported for XML");
+ }
+ return "setCleanCount";
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("count", count);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDustbinAutoEmptyCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDustbinAutoEmptyCommand.java
new file mode 100644
index 0000000000000..ea4c4ba0cbf3b
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDustbinAutoEmptyCommand.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2023 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 SetDustbinAutoEmptyCommand extends AbstractNoResponseCommand {
+ private final boolean on;
+
+ public SetDustbinAutoEmptyCommand(boolean on) {
+ this.on = on;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Set dust bin auto empty is not supported for XML");
+ }
+ return "setAutoEmpty";
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("enable", on ? 1 : 0);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetMoppingWaterAmountCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetMoppingWaterAmountCommand.java
new file mode 100644
index 0000000000000..5488f21b6b73f
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetMoppingWaterAmountCommand.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2023 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 org.openhab.binding.ecovacs.internal.api.model.MoppingWaterAmount;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class SetMoppingWaterAmountCommand extends AbstractNoResponseCommand {
+ private final int level;
+
+ public SetMoppingWaterAmountCommand(MoppingWaterAmount amount) {
+ super();
+ this.level = amount.toApiValue();
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "SetWaterPermeability" : "setWaterInfo";
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("v", String.valueOf(level));
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("amount", level);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetSuctionPowerCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetSuctionPowerCommand.java
new file mode 100644
index 0000000000000..f498eec1ed835
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetSuctionPowerCommand.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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 org.openhab.binding.ecovacs.internal.api.model.SuctionPower;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class SetSuctionPowerCommand extends AbstractNoResponseCommand {
+ private final SuctionPower power;
+
+ public SetSuctionPowerCommand(SuctionPower power) {
+ this.power = power;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ return version == ProtocolVersion.XML ? "SetCleanSpeed" : "setSpeed";
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("speed", power.toJsonValue());
+ return args;
+ }
+
+ @Override
+ protected void applyXmlPayload(Document doc, Element ctl) {
+ ctl.setAttribute("speed", power.toXmlValue());
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetTrueDetectCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetTrueDetectCommand.java
new file mode 100644
index 0000000000000..3d977e528b16b
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetTrueDetectCommand.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2023 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 SetTrueDetectCommand extends AbstractNoResponseCommand {
+ private final boolean on;
+
+ public SetTrueDetectCommand(boolean on) {
+ this.on = on;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Set true detect is not supported for XML");
+ }
+ return "setTrueDetect";
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("enable", on ? 1 : 0);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetVolumeCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetVolumeCommand.java
new file mode 100644
index 0000000000000..8cf64ba28d585
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetVolumeCommand.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright (c) 2010-2023 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 SetVolumeCommand extends AbstractNoResponseCommand {
+ private final int volume;
+
+ public SetVolumeCommand(int volume) {
+ if (volume < 0 || volume > 10) {
+ throw new IllegalArgumentException("Volume must be between 0 and 10");
+ }
+ this.volume = volume;
+ }
+
+ @Override
+ public String getName(ProtocolVersion version) {
+ if (version == ProtocolVersion.XML) {
+ throw new IllegalStateException("Set volume is not supported for XML");
+ }
+ return "setVolume";
+ }
+
+ @Override
+ protected @Nullable JsonElement getJsonPayloadArgs(ProtocolVersion version) {
+ JsonObject args = new JsonObject();
+ args.addProperty("volume", volume);
+ return args;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SpotAreaCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SpotAreaCleaningCommand.java
new file mode 100644
index 0000000000000..9a693cfaa5ca6
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SpotAreaCleaningCommand.java
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class SpotAreaCleaningCommand extends AbstractAreaCleaningCommand {
+ public SpotAreaCleaningCommand(List roomIds, int cleanPasses) {
+ super("spotArea", String.join(",", roomIds), cleanPasses);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StartAutoCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StartAutoCleaningCommand.java
new file mode 100644
index 0000000000000..999a32dd4bf02
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StartAutoCleaningCommand.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.model.CleanMode;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class StartAutoCleaningCommand extends AbstractCleaningCommand {
+ public StartAutoCleaningCommand() {
+ super("s", "start", CleanMode.AUTO);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StopCleaningCommand.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StopCleaningCommand.java
new file mode 100644
index 0000000000000..369c40738cbf5
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StopCleaningCommand.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.model.CleanMode;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class StopCleaningCommand extends AbstractCleaningCommand {
+ public StopCleaningCommand() {
+ super("h", "stop", CleanMode.STOP);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/DeviceDescription.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/DeviceDescription.java
new file mode 100644
index 0000000000000..c8792abfe301a
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/DeviceDescription.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceDescription {
+ public final String modelName;
+ public final String deviceClass;
+ public final @Nullable String deviceClassLink;
+ public final ProtocolVersion protoVersion;
+ public final boolean usesMqtt;
+ public final Set capabilities;
+
+ public DeviceDescription(String modelName, String deviceClass, @Nullable String deviceClassLink,
+ ProtocolVersion protoVersion, boolean usesMqtt, Set capabilities) {
+ this.modelName = modelName;
+ this.capabilities = capabilities;
+ this.deviceClass = deviceClass;
+ this.deviceClassLink = deviceClassLink;
+ this.protoVersion = protoVersion;
+ this.usesMqtt = usesMqtt;
+ }
+
+ public DeviceDescription resolveLinkWith(DeviceDescription other) {
+ return new DeviceDescription(modelName, deviceClass, null, other.protoVersion, other.usesMqtt,
+ other.capabilities);
+ }
+
+ public void addImplicitCapabilities() {
+ if (protoVersion != ProtocolVersion.XML && capabilities.contains(DeviceCapability.CLEAN_SPEED_CONTROL)) {
+ capabilities.add(DeviceCapability.EXTENDED_CLEAN_SPEED_CONTROL);
+ }
+ if (protoVersion != ProtocolVersion.XML) {
+ capabilities.add(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD);
+ }
+ if (!capabilities.contains(DeviceCapability.SPOT_AREA_CLEANING)) {
+ capabilities.add(DeviceCapability.EDGE_CLEANING);
+ capabilities.add(DeviceCapability.SPOT_CLEANING);
+ }
+ if (protoVersion == ProtocolVersion.JSON_V2) {
+ capabilities.add(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiImpl.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiImpl.java
new file mode 100644
index 0000000000000..2cf1f784b9833
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiImpl.java
@@ -0,0 +1,361 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.TransformerException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalAuthRequest;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalAuthRequestParameter;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalCleanLogsRequest;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotCommandRequest;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotProductRequest;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalLoginRequest;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.AccessData;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.AuthCode;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.ResponseWrapper;
+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.PortalCleanLogsResponse;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public final class EcovacsApiImpl implements EcovacsApi {
+ private final Logger logger = LoggerFactory.getLogger(EcovacsApiImpl.class);
+
+ private final HttpClient httpClient;
+ private final Gson gson = new Gson();
+
+ private final EcovacsApiConfiguration configuration;
+ private @Nullable PortalLoginResponse loginData;
+
+ public EcovacsApiImpl(HttpClient httpClient, EcovacsApiConfiguration configuration) {
+ this.httpClient = httpClient;
+ this.configuration = configuration;
+ }
+
+ @Override
+ public void loginAndGetAccessToken() throws EcovacsApiException, InterruptedException {
+ loginData = null;
+
+ AccessData accessData = login();
+ AuthCode authCode = getAuthCode(accessData);
+ loginData = portalLogin(authCode, accessData);
+ }
+
+ EcovacsApiConfiguration getConfig() {
+ return configuration;
+ }
+
+ @Nullable
+ PortalLoginResponse getLoginData() {
+ return loginData;
+ }
+
+ private AccessData login() throws EcovacsApiException, InterruptedException {
+ HashMap 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("authTimeZone", configuration.getTimeZone());
+ loginParameters.put("country", configuration.getCountry());
+ loginParameters.put("lang", configuration.getLanguage());
+ loginParameters.put("deviceId", configuration.getDeviceId());
+ loginParameters.put("appCode", configuration.getAppCode());
+ loginParameters.put("appVersion", configuration.getAppVersion());
+ loginParameters.put("channel", configuration.getChannel());
+ loginParameters.put("deviceType", configuration.getDeviceType());
+
+ Request loginRequest = createAuthRequest(EcovacsApiUrlFactory.getLoginUrl(configuration),
+ configuration.getClientKey(), configuration.getClientSecret(), loginParameters);
+ ContentResponse loginResponse = executeRequest(loginRequest);
+ Type responseType = new TypeToken>() {
+ }.getType();
+ return handleResponseWrapper(gson.fromJson(loginResponse.getContentAsString(), responseType));
+ }
+
+ private AuthCode getAuthCode(AccessData accessData) throws EcovacsApiException, InterruptedException {
+ HashMap authCodeParameters = new HashMap<>();
+ authCodeParameters.put("uid", accessData.getUid());
+ authCodeParameters.put("accessToken", accessData.getAccessToken());
+ authCodeParameters.put("bizType", configuration.getBizType());
+ authCodeParameters.put("deviceId", configuration.getDeviceId());
+ authCodeParameters.put("openId", configuration.getAuthOpenId());
+
+ Request authCodeRequest = createAuthRequest(EcovacsApiUrlFactory.getAuthUrl(configuration),
+ configuration.getAuthClientKey(), configuration.getAuthClientSecret(), authCodeParameters);
+ ContentResponse authCodeResponse = executeRequest(authCodeRequest);
+ Type responseType = new TypeToken>() {
+ }.getType();
+ return handleResponseWrapper(gson.fromJson(authCodeResponse.getContentAsString(), responseType));
+ }
+
+ private PortalLoginResponse portalLogin(AuthCode authCode, AccessData accessData)
+ throws EcovacsApiException, InterruptedException {
+ PortalLoginRequest loginRequestData = new PortalLoginRequest(PortalTodo.LOGIN_BY_TOKEN,
+ configuration.getCountry().toUpperCase(), "", configuration.getOrg(), configuration.getResource(),
+ configuration.getRealm(), authCode.getAuthCode(), accessData.getUid(), configuration.getEdition());
+ String userUrl = EcovacsApiUrlFactory.getPortalUsersUrl(configuration);
+ ContentResponse portalLoginResponse = executeRequest(createJsonRequest(userUrl, loginRequestData));
+ PortalLoginResponse response = handleResponse(portalLoginResponse, PortalLoginResponse.class);
+ if (!response.wasSuccessful()) {
+ throw new EcovacsApiException("Login failed");
+ }
+ return response;
+ }
+
+ @Override
+ public List getDevices() throws EcovacsApiException, InterruptedException {
+ List descriptions = getSupportedDeviceList();
+ List products = null;
+ List devices = new ArrayList<>();
+ for (Device dev : getDeviceList()) {
+ Optional descOpt = descriptions.stream()
+ .filter(d -> dev.getDeviceClass().equals(d.deviceClass)).findFirst();
+ if (!descOpt.isPresent()) {
+ if (products == null) {
+ products = getIotProductMap();
+ }
+ String modelName = products.stream().filter(prod -> dev.getDeviceClass().equals(prod.getClassId()))
+ .findFirst().map(p -> p.getDefinition().name).orElse("UNKNOWN");
+ logger.info("Found unsupported device {} (class {}, company {}), ignoring.", modelName,
+ dev.getDeviceClass(), dev.getCompany());
+ continue;
+ }
+ DeviceDescription desc = descOpt.get();
+ if (desc.usesMqtt) {
+ devices.add(new EcovacsIotMqDevice(dev, desc, this, gson));
+ } else {
+ devices.add(new EcovacsXmppDevice(dev, desc, this, gson));
+ }
+ }
+ return devices;
+ }
+
+ private List getSupportedDeviceList() {
+ ClassLoader cl = Objects.requireNonNull(getClass().getClassLoader());
+ InputStream is = cl.getResourceAsStream("devices/supported_device_list.json");
+ JsonReader reader = new JsonReader(new InputStreamReader(is));
+ Type type = new TypeToken>() {
+ }.getType();
+ List descs = gson.fromJson(reader, type);
+ return descs.stream().map(desc -> {
+ final DeviceDescription result;
+ if (desc.deviceClassLink != null) {
+ Optional linkedDescOpt = descs.stream()
+ .filter(d -> d.deviceClass.equals(desc.deviceClassLink)).findFirst();
+ if (!linkedDescOpt.isPresent()) {
+ throw new IllegalStateException(
+ "Desc " + desc.deviceClass + " links unknown desc " + desc.deviceClassLink);
+ }
+ result = desc.resolveLinkWith(linkedDescOpt.get());
+ } else {
+ result = desc;
+ }
+ result.addImplicitCapabilities();
+ return result;
+ }).collect(Collectors.toList());
+ }
+
+ private List getDeviceList() throws EcovacsApiException, InterruptedException {
+ PortalAuthRequest data = new PortalAuthRequest(PortalTodo.GET_DEVICE_LIST, createAuthData());
+ String userUrl = EcovacsApiUrlFactory.getPortalUsersUrl(configuration);
+ ContentResponse deviceResponse = executeRequest(createJsonRequest(userUrl, data));
+ logger.trace("Got device list response {}", deviceResponse.getContentAsString());
+ List devices = handleResponse(deviceResponse, PortalDeviceResponse.class).getDevices();
+ return devices != null ? devices : Collections.emptyList();
+ }
+
+ private List getIotProductMap() throws EcovacsApiException, InterruptedException {
+ PortalIotProductRequest data = new PortalIotProductRequest(createAuthData());
+ String url = EcovacsApiUrlFactory.getPortalProductIotMapUrl(configuration);
+ ContentResponse productResponse = executeRequest(createJsonRequest(url, data));
+ logger.trace("Got product list response {}", productResponse.getContentAsString());
+ List products = handleResponse(productResponse, PortalIotProductResponse.class).getProducts();
+ return products != null ? products : Collections.emptyList();
+ }
+
+ public T sendIotCommand(Device device, DeviceDescription desc, IotDeviceCommand command)
+ throws EcovacsApiException, InterruptedException {
+ String commandName = command.getName(desc.protoVersion);
+ final Object payload;
+ try {
+ if (desc.protoVersion == ProtocolVersion.XML) {
+ payload = command.getXmlPayload(null);
+ logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName, payload);
+ } else {
+ payload = command.getJsonPayload(desc.protoVersion, gson);
+ logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName,
+ gson.toJson(payload));
+ }
+ } catch (ParserConfigurationException | TransformerException e) {
+ logger.debug("Could not convert payload for {}", command, e);
+ throw new EcovacsApiException(e);
+ }
+
+ PortalIotCommandRequest data = new PortalIotCommandRequest(createAuthData(), commandName, payload,
+ device.getDid(), device.getResource(), device.getDeviceClass(),
+ desc.protoVersion != ProtocolVersion.XML);
+ String url = EcovacsApiUrlFactory.getPortalIotDeviceManagerUrl(configuration);
+ ContentResponse response = executeRequest(createJsonRequest(url, data));
+
+ final AbstractPortalIotCommandResponse commandResponse;
+ if (desc.protoVersion == ProtocolVersion.XML) {
+ commandResponse = handleResponse(response, PortalIotCommandXmlResponse.class);
+ logger.trace("{}: Got response payload {}", device.getName(),
+ ((PortalIotCommandXmlResponse) commandResponse).getResponsePayloadXml());
+ } else {
+ commandResponse = handleResponse(response, PortalIotCommandJsonResponse.class);
+ logger.trace("{}: Got response payload {}", device.getName(),
+ ((PortalIotCommandJsonResponse) commandResponse).response);
+ }
+ if (!commandResponse.wasSuccessful()) {
+ final String msg = "Sending IOT command " + commandName + " failed: " + commandResponse.getErrorMessage();
+ throw new EcovacsApiException(msg, commandResponse.failedDueToAuthProblem());
+ }
+ try {
+ return command.convertResponse(commandResponse, desc.protoVersion, gson);
+ } catch (DataParsingException e) {
+ logger.debug("Converting response for command {} failed", command, e);
+ throw new EcovacsApiException(e);
+ }
+ }
+
+ public List fetchCleanLogs(Device device)
+ throws EcovacsApiException, InterruptedException {
+ PortalCleanLogsRequest data = new PortalCleanLogsRequest(createAuthData(), device.getDid(),
+ device.getResource());
+ String url = EcovacsApiUrlFactory.getPortalLogUrl(configuration);
+ ContentResponse response = executeRequest(createJsonRequest(url, data));
+ PortalCleanLogsResponse responseObj = handleResponse(response, PortalCleanLogsResponse.class);
+ if (!responseObj.wasSuccessful()) {
+ throw new EcovacsApiException("Fetching clean logs failed");
+ }
+ logger.trace("{}: Fetching cleaning logs yields {} records", device.getName(), responseObj.records.size());
+ return responseObj.records;
+ }
+
+ private PortalAuthRequestParameter createAuthData() {
+ PortalLoginResponse loginData = this.loginData;
+ if (loginData == null) {
+ throw new IllegalStateException("Not logged in");
+ }
+ return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
+ configuration.getRealm(), loginData.getToken(), configuration.getResource());
+ }
+
+ private T handleResponseWrapper(@Nullable ResponseWrapper response) throws EcovacsApiException {
+ if (response == null) {
+ // should not happen in practice
+ throw new EcovacsApiException("No response received");
+ }
+ if (!response.isSuccess()) {
+ throw new EcovacsApiException("API call failed: " + response.getMessage() + ", code " + response.getCode());
+ }
+ return response.getData();
+ }
+
+ private T handleResponse(ContentResponse response, Class clazz) throws EcovacsApiException {
+ @Nullable
+ T respObject = gson.fromJson(response.getContentAsString(), clazz);
+ if (respObject == null) {
+ // should not happen in practice
+ throw new EcovacsApiException("No response received");
+ }
+ return respObject;
+ }
+
+ private Request createAuthRequest(String url, String clientKey, String clientSecret,
+ Map requestSpecificParameters) {
+ HashMap signedRequestParameters = new HashMap<>(requestSpecificParameters);
+ signedRequestParameters.put("authTimespan", String.valueOf(System.currentTimeMillis()));
+
+ StringBuilder signOnText = new StringBuilder(clientKey);
+ signedRequestParameters.keySet().stream().sorted().forEach(key -> {
+ signOnText.append(key).append("=").append(signedRequestParameters.get(key));
+ });
+ signOnText.append(clientSecret);
+
+ signedRequestParameters.put("authAppkey", clientKey);
+ signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
+
+ Request request = httpClient.newRequest(url).method(HttpMethod.GET);
+ signedRequestParameters.forEach(request::param);
+
+ return request;
+ }
+
+ 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 ContentResponse executeRequest(Request request) throws EcovacsApiException, InterruptedException {
+ request.timeout(10, TimeUnit.SECONDS);
+ try {
+ ContentResponse response = request.send();
+ if (response.getStatus() != HttpStatus.OK_200) {
+ throw new EcovacsApiException(response);
+ }
+ return response;
+ } catch (TimeoutException | ExecutionException e) {
+ throw new EcovacsApiException(e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiUrlFactory.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiUrlFactory.java
new file mode 100644
index 0000000000000..745bf37b6d5a8
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiUrlFactory.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public final class EcovacsApiUrlFactory {
+
+ private EcovacsApiUrlFactory() {
+ // Prevent instantiation
+ }
+
+ 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";
+
+ public static String getLoginUrl(EcovacsApiConfiguration config) {
+ return getMainUrl(config) + MAIN_URL_LOGIN_PATH;
+ }
+
+ public static String getAuthUrl(EcovacsApiConfiguration config) {
+ return String.format("https://gl-%1$s-openapi.ecovacs.%2$s/v1/global/auth/getAuthCode", config.getCountry(),
+ getApiUrlTld(config));
+ }
+
+ public static String getPortalUsersUrl(EcovacsApiConfiguration config) {
+ return getPortalUrl(config) + PORTAL_USERS_PATH;
+ }
+
+ public static String getPortalProductIotMapUrl(EcovacsApiConfiguration config) {
+ return getPortalUrl(config) + PORTAL_IOT_PRODUCT_PATH;
+ }
+
+ public static String getPortalIotDeviceManagerUrl(EcovacsApiConfiguration config) {
+ return getPortalUrl(config) + PORTAL_IOT_DEVMANAGER_PATH;
+ }
+
+ public static String getPortalLogUrl(EcovacsApiConfiguration config) {
+ return getPortalUrl(config) + PORTAL_LOG_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);
+ }
+
+ private static String getMainUrl(EcovacsApiConfiguration config) {
+ return String.format("https://gl-%1$s-api.ecovacs.%2$s/v1/private/%1$s/%3$s/%4$s/%5$s/%6$s/%7$s/%8$s",
+ config.getCountry(), getApiUrlTld(config), config.getLanguage(), config.getDeviceId(),
+ config.getAppCode(), config.getAppVersion(), config.getChannel(), config.getDeviceType());
+ }
+
+ private static String getApiUrlTld(EcovacsApiConfiguration config) {
+ return "cn".equalsIgnoreCase(config.getCountry()) ? "cn" : "com";
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsIotMqDevice.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsIotMqDevice.java
new file mode 100644
index 0000000000000..fe57fd3f2306a
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsIotMqDevice.java
@@ -0,0 +1,211 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.security.KeyStore;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.net.ssl.ManagerFactoryParameters;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.TrustManagerFactory;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+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.PortalLoginResponse;
+import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
+import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.core.io.net.http.TrustAllTrustManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.hivemq.client.mqtt.MqttClient;
+import com.hivemq.client.mqtt.MqttClientSslConfig;
+import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener;
+import com.hivemq.client.mqtt.lifecycle.MqttDisconnectSource;
+import com.hivemq.client.mqtt.mqtt3.Mqtt3AsyncClient;
+import com.hivemq.client.mqtt.mqtt3.exceptions.Mqtt3ConnAckException;
+import com.hivemq.client.mqtt.mqtt3.exceptions.Mqtt3DisconnectException;
+import com.hivemq.client.mqtt.mqtt3.message.auth.Mqtt3SimpleAuth;
+import com.hivemq.client.mqtt.mqtt3.message.connect.connack.Mqtt3ConnAckReturnCode;
+import com.hivemq.client.mqtt.mqtt3.message.publish.Mqtt3Publish;
+
+import io.netty.handler.ssl.util.SimpleTrustManagerFactory;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsIotMqDevice implements EcovacsDevice {
+ private final Logger logger = LoggerFactory.getLogger(EcovacsIotMqDevice.class);
+
+ private final Device device;
+ private final DeviceDescription desc;
+ private final EcovacsApiImpl api;
+ private final Gson gson;
+ private @Nullable Mqtt3AsyncClient mqttClient;
+
+ EcovacsIotMqDevice(Device device, DeviceDescription desc, EcovacsApiImpl api, Gson gson)
+ throws EcovacsApiException {
+ this.device = device;
+ this.desc = desc;
+ this.api = api;
+ this.gson = gson;
+ }
+
+ @Override
+ public String getSerialNumber() {
+ return device.getName();
+ }
+
+ @Override
+ public String getModelName() {
+ return desc.modelName;
+ }
+
+ @Override
+ public boolean hasCapability(DeviceCapability cap) {
+ return desc.capabilities.contains(cap);
+ }
+
+ @Override
+ public T sendCommand(IotDeviceCommand command) throws EcovacsApiException, InterruptedException {
+ return api.sendIotCommand(device, desc, command);
+ }
+
+ @Override
+ public List getCleanLogs() throws EcovacsApiException, InterruptedException {
+ Stream logEntries;
+ 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));
+ }
+ return logEntries.sorted((lhs, rhs) -> rhs.timestamp.compareTo(lhs.timestamp)).collect(Collectors.toList());
+ }
+
+ @Override
+ public void connect(final EventListener listener, ScheduledExecutorService scheduler)
+ throws EcovacsApiException, InterruptedException {
+ EcovacsApiConfiguration config = api.getConfig();
+ PortalLoginResponse loginData = api.getLoginData();
+ if (loginData == null) {
+ throw new EcovacsApiException("Can not connect when not logged in");
+ }
+
+ // XML message handler does not receive firmware version information with events, so fetch in advance
+ if (desc.protoVersion == ProtocolVersion.XML) {
+ listener.onFirmwareVersionChanged(this, sendCommand(new GetFirmwareVersionCommand()));
+ }
+
+ String userName = String.format("%s@%s", loginData.getUserId(), config.getRealm().split("\\.")[0]);
+ String host = String.format("mq-%s.%s", config.getContinent(), config.getRealm());
+
+ Mqtt3SimpleAuth auth = Mqtt3SimpleAuth.builder().username(userName).password(loginData.getToken().getBytes())
+ .build();
+
+ MqttClientSslConfig sslConfig = MqttClientSslConfig.builder().trustManagerFactory(createTrustManagerFactory())
+ .build();
+
+ final MqttClientDisconnectedListener disconnectListener = ctx -> {
+ boolean expectedShutdown = ctx.getSource() == MqttDisconnectSource.USER
+ && ctx.getCause() instanceof Mqtt3DisconnectException;
+ // As the client already was disconnected, there's no need to do it again in disconnect() later
+ this.mqttClient = null;
+ if (!expectedShutdown) {
+ logger.debug("{}: MQTT disconnected (source {}): {}", getSerialNumber(), ctx.getSource(),
+ ctx.getCause().getMessage());
+ listener.onEventStreamFailure(EcovacsIotMqDevice.this, ctx.getCause());
+ }
+ };
+
+ final Mqtt3AsyncClient client = MqttClient.builder().useMqttVersion3()
+ .identifier(userName + "/" + loginData.getResource()).simpleAuth(auth).serverHost(host).serverPort(8883)
+ .sslConfig(sslConfig).addDisconnectedListener(disconnectListener).buildAsync();
+
+ try {
+ this.mqttClient = client;
+ client.connect().get();
+
+ final ReportParser parser = desc.protoVersion == ProtocolVersion.XML
+ ? new XmlReportParser(this, listener, gson, logger)
+ : new JsonReportParser(this, listener, desc.protoVersion, gson, logger);
+ final Consumer<@Nullable Mqtt3Publish> eventCallback = publish -> {
+ if (publish == null) {
+ return;
+ }
+ String receivedTopic = publish.getTopic().toString();
+ String payload = new String(publish.getPayloadAsBytes());
+ try {
+ String eventName = receivedTopic.split("/")[2].toLowerCase();
+ logger.trace("{}: Got MQTT message on topic {}: {}", getSerialNumber(), receivedTopic, payload);
+ parser.handleMessage(eventName, payload);
+ } catch (DataParsingException e) {
+ listener.onEventStreamFailure(this, e);
+ }
+ };
+
+ String topic = String.format("iot/atr/+/%s/%s/%s/+", device.getDid(), device.getDeviceClass(),
+ device.getResource());
+
+ client.subscribeWith().topicFilter(topic).callback(eventCallback).send().get();
+ logger.debug("Established MQTT connection to device {}", getSerialNumber());
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ boolean isAuthFailure = cause instanceof Mqtt3ConnAckException && ((Mqtt3ConnAckException) cause)
+ .getMqttMessage().getReturnCode() == Mqtt3ConnAckReturnCode.NOT_AUTHORIZED;
+ throw new EcovacsApiException(e, isAuthFailure);
+ }
+ }
+
+ @Override
+ public void disconnect(ScheduledExecutorService scheduler) {
+ Mqtt3AsyncClient client = this.mqttClient;
+ if (client != null) {
+ client.disconnect();
+ }
+ this.mqttClient = null;
+ }
+
+ private TrustManagerFactory createTrustManagerFactory() {
+ return new SimpleTrustManagerFactory() {
+ @Override
+ protected void engineInit(@Nullable KeyStore keyStore) throws Exception {
+ }
+
+ @Override
+ protected void engineInit(@Nullable ManagerFactoryParameters managerFactoryParameters) throws Exception {
+ }
+
+ @Override
+ protected TrustManager[] engineGetTrustManagers() {
+ return new TrustManager[] { TrustAllTrustManager.getInstance() };
+ }
+ };
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsXmppDevice.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsXmppDevice.java
new file mode 100644
index 0000000000000..193ed6ad993b5
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsXmppDevice.java
@@ -0,0 +1,467 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledExecutorService;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.transform.TransformerException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.jivesoftware.smack.ConnectionListener;
+import org.jivesoftware.smack.SmackException;
+import org.jivesoftware.smack.XMPPConnection;
+import org.jivesoftware.smack.XMPPException;
+import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
+import org.jivesoftware.smack.packet.ErrorIQ;
+import org.jivesoftware.smack.packet.IQ;
+import org.jivesoftware.smack.packet.IQ.Type;
+import org.jivesoftware.smack.packet.StanzaError;
+import org.jivesoftware.smack.provider.IQProvider;
+import org.jivesoftware.smack.provider.ProviderManager;
+import org.jivesoftware.smack.roster.Roster;
+import org.jivesoftware.smack.sasl.SASLErrorException;
+import org.jivesoftware.smack.tcp.XMPPTCPConnection;
+import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration;
+import org.jivesoftware.smack.util.PacketParserUtils;
+import org.jivesoftware.smackx.ping.PingManager;
+import org.jxmpp.jid.Jid;
+import org.jxmpp.jid.impl.JidCreate;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+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.PortalIotCommandXmlResponse;
+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;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.SchedulerTask;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.openhab.core.io.net.http.TrustAllTrustManager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.xmlpull.v1.XmlPullParser;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsXmppDevice implements EcovacsDevice {
+ private final Logger logger = LoggerFactory.getLogger(EcovacsXmppDevice.class);
+
+ private final Device device;
+ private final DeviceDescription desc;
+ private final EcovacsApiImpl api;
+ private final Gson gson;
+ private @Nullable IncomingMessageHandler messageHandler;
+ private @Nullable PingHandler pingHandler;
+ private @Nullable XMPPTCPConnection connection;
+ private @Nullable Jid ownAddress;
+ private @Nullable Jid targetAddress;
+
+ EcovacsXmppDevice(Device device, DeviceDescription desc, EcovacsApiImpl api, Gson gson) {
+ this.device = device;
+ this.desc = desc;
+ this.api = api;
+ this.gson = gson;
+ }
+
+ @Override
+ public String getSerialNumber() {
+ return device.getName();
+ }
+
+ @Override
+ public String getModelName() {
+ return desc.modelName;
+ }
+
+ @Override
+ public boolean hasCapability(DeviceCapability cap) {
+ return desc.capabilities.contains(cap);
+ }
+
+ @Override
+ public T sendCommand(IotDeviceCommand command) throws EcovacsApiException, InterruptedException {
+ IncomingMessageHandler handler = this.messageHandler;
+ XMPPConnection conn = this.connection;
+ Jid from = this.ownAddress;
+ Jid to = this.targetAddress;
+ if (handler == null || conn == null || from == null || to == null) {
+ throw new EcovacsApiException("Not connected to device");
+ }
+
+ try {
+ // Devices sometimes send no answer to commands for unknown reasons. Ecovacs'
+ // app employs a similar retry mechanism, so this seems to be 'normal'.
+ for (int retry = 0; retry < 3; retry++) {
+ DeviceCommandIQ request = new DeviceCommandIQ(command, from, to);
+ CommandResponseHolder responseHolder = new CommandResponseHolder();
+
+ try {
+ handler.registerPendingCommand(request.id, responseHolder);
+
+ logger.trace("{}: sending command {}, retry {}", getSerialNumber(),
+ command.getName(ProtocolVersion.XML), retry);
+ synchronized (responseHolder) {
+ conn.sendIqRequestAsync(request);
+ responseHolder.wait(1500);
+ }
+ } finally {
+ handler.unregisterPendingCommand(request.id);
+ }
+
+ String response = responseHolder.response;
+ if (response != null) {
+ logger.trace("{}: Received command response XML {}", getSerialNumber(), response);
+
+ PortalIotCommandXmlResponse responseObj = new PortalIotCommandXmlResponse("", response, 0, "");
+ return command.convertResponse(responseObj, ProtocolVersion.XML, gson);
+ }
+ }
+ } catch (DataParsingException | ParserConfigurationException | TransformerException e) {
+ throw new EcovacsApiException(e);
+ }
+
+ throw new EcovacsApiException("No response for command " + command.getName(ProtocolVersion.XML));
+ }
+
+ @Override
+ public List getCleanLogs() throws EcovacsApiException, InterruptedException {
+ return sendCommand(new GetCleanLogsCommand());
+ }
+
+ @Override
+ public void connect(final EventListener listener, final ScheduledExecutorService scheduler)
+ throws EcovacsApiException {
+ EcovacsApiConfiguration config = api.getConfig();
+ PortalLoginResponse loginData = api.getLoginData();
+ if (loginData == null) {
+ throw new EcovacsApiException("Can not connect when not logged in");
+ }
+
+ logger.trace("{}: Connecting to XMPP", getSerialNumber());
+
+ String password = String.format("0/%s/%s", loginData.getResource(), loginData.getToken());
+ String host = String.format("msg-%s.%s", config.getContinent(), config.getRealm());
+
+ try {
+ Jid ownAddress = JidCreate.from(loginData.getUserId(), config.getRealm(), loginData.getResource());
+ Jid targetAddress = JidCreate.from(device.getDid(), device.getDeviceClass() + ".ecorobot.net", "atom");
+
+ XMPPTCPConnectionConfiguration connConfig = XMPPTCPConnectionConfiguration.builder().setHost(host)
+ .setPort(5223).setUsernameAndPassword(loginData.getUserId(), password)
+ .setResource(loginData.getResource()).setXmppDomain(config.getRealm())
+ .setCustomX509TrustManager(TrustAllTrustManager.getInstance()).setSendPresence(false).build();
+
+ XMPPTCPConnection conn = new XMPPTCPConnection(connConfig);
+ conn.addConnectionListener(new ConnectionListener() {
+ @Override
+ public void connected(@Nullable XMPPConnection connection) {
+ }
+
+ @Override
+ public void authenticated(@Nullable XMPPConnection connection, boolean resumed) {
+ }
+
+ @Override
+ public void connectionClosed() {
+ }
+
+ @Override
+ public void connectionClosedOnError(@Nullable Exception e) {
+ logger.trace("{}: XMPP connection failed", getSerialNumber(), e);
+ if (e != null) {
+ listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
+ }
+ }
+ });
+
+ PingHandler pingHandler = new PingHandler(conn, scheduler, listener, targetAddress);
+ messageHandler = new IncomingMessageHandler(listener);
+
+ Roster roster = Roster.getInstanceFor(conn);
+ roster.setRosterLoadedAtLogin(false);
+
+ conn.registerIQRequestHandler(messageHandler);
+ conn.connect();
+
+ this.connection = conn;
+ this.ownAddress = ownAddress;
+ this.targetAddress = targetAddress;
+ this.pingHandler = pingHandler;
+
+ conn.login();
+ conn.setReplyTimeout(1000);
+
+ logger.trace("{}: XMPP connection established", getSerialNumber());
+
+ listener.onFirmwareVersionChanged(this, sendCommand(new GetFirmwareVersionCommand()));
+ pingHandler.start();
+ } catch (SASLErrorException e) {
+ throw new EcovacsApiException(e, true);
+ } catch (XMPPException | SmackException | InterruptedException | IOException e) {
+ throw new EcovacsApiException(e);
+ }
+ }
+
+ @Override
+ public void disconnect(ScheduledExecutorService scheduler) {
+ PingHandler pingHandler = this.pingHandler;
+ if (pingHandler != null) {
+ pingHandler.stop();
+ }
+ this.pingHandler = null;
+
+ IncomingMessageHandler handler = this.messageHandler;
+ if (handler != null) {
+ handler.dispose();
+ }
+ this.messageHandler = null;
+
+ final XMPPTCPConnection conn = this.connection;
+ if (conn != null) {
+ scheduler.execute(() -> conn.disconnect());
+ }
+ this.connection = null;
+ }
+
+ private class PingHandler {
+ private static final long INTERVAL_SECONDS = 30;
+ // After a failure, use shorter intervals since subsequent further failure is likely
+ private static final long POST_FAILURE_INTERVAL_SECONDS = 5;
+ private static final int MAX_FAILURES = 4;
+
+ private final XMPPTCPConnection connection;
+ private final PingManager pingManager;
+ private final EventListener listener;
+ private final Jid toAddress;
+ private final SchedulerTask pingTask;
+ private boolean started = false;
+ private int failedPings = 0;
+
+ PingHandler(XMPPTCPConnection connection, ScheduledExecutorService scheduler, EventListener listener, Jid to) {
+ this.connection = connection;
+ this.pingManager = PingManager.getInstanceFor(connection);
+ this.pingTask = new SchedulerTask(scheduler, logger, "Ping", this::sendPing);
+ this.listener = listener;
+ this.toAddress = to;
+ this.pingTask.setNamePrefix(getSerialNumber());
+ }
+
+ public void start() {
+ started = true;
+ scheduleNextPing(0);
+ }
+
+ public void stop() {
+ started = false;
+ pingTask.cancel();
+ }
+
+ private void sendPing() {
+ long timeSinceLastStanza = (System.currentTimeMillis() - connection.getLastStanzaReceived()) / 1000;
+ if (timeSinceLastStanza < currentPingInterval()) {
+ scheduleNextPing(timeSinceLastStanza);
+ return;
+ }
+
+ try {
+ if (pingManager.ping(this.toAddress)) {
+ logger.trace("{}: Pinged device", getSerialNumber());
+ failedPings = 0;
+ }
+ } catch (InterruptedException e) {
+ // only happens when we're stopped
+ } catch (SmackException e) {
+ ++failedPings;
+ logger.debug("{}: Ping failed (#{}): {})", getSerialNumber(), failedPings, e.getMessage());
+ if (failedPings >= MAX_FAILURES) {
+ listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
+ }
+ }
+ scheduleNextPing(0);
+ }
+
+ private synchronized void scheduleNextPing(long delta) {
+ pingTask.cancel();
+ if (started) {
+ pingTask.schedule(currentPingInterval() - delta);
+ }
+ }
+
+ private long currentPingInterval() {
+ return failedPings > 0 ? POST_FAILURE_INTERVAL_SECONDS : INTERVAL_SECONDS;
+ }
+ }
+
+ private class IncomingMessageHandler extends AbstractIqRequestHandler {
+ private final EventListener listener;
+ private final ReportParser parser;
+ private final ConcurrentHashMap pendingCommands = new ConcurrentHashMap<>();
+ private boolean disposed;
+
+ IncomingMessageHandler(EventListener listener) {
+ super("query", "com:ctl", Type.set, Mode.async);
+ this.listener = listener;
+ this.parser = new XmlReportParser(EcovacsXmppDevice.this, listener, gson, logger);
+ }
+
+ void registerPendingCommand(String id, CommandResponseHolder responseHolder) {
+ pendingCommands.put(id, responseHolder);
+ }
+
+ void unregisterPendingCommand(String id) {
+ pendingCommands.remove(id);
+ }
+
+ void dispose() {
+ disposed = true;
+ }
+
+ @Override
+ public @Nullable IQ handleIQRequest(@Nullable IQ iqRequest) {
+ if (disposed) {
+ return null;
+ }
+
+ if (iqRequest instanceof DeviceCommandIQ) {
+ DeviceCommandIQ iq = (DeviceCommandIQ) iqRequest;
+
+ try {
+ if (!iq.id.isEmpty()) {
+ CommandResponseHolder responseHolder = pendingCommands.remove(iq.id);
+ if (responseHolder != null) {
+ synchronized (responseHolder) {
+ responseHolder.response = iq.payload;
+ responseHolder.notifyAll();
+ }
+ }
+ } else {
+ Optional eventNameOpt = XPathUtils.getFirstXPathMatchOpt(iq.payload, "//ctl/@td")
+ .map(n -> n.getNodeValue());
+ if (eventNameOpt.isPresent()) {
+ logger.trace("{}: Received event message XML {}", getSerialNumber(), iq.payload);
+ parser.handleMessage(eventNameOpt.get(), iq.payload);
+ } else {
+ logger.debug("{}: Got unexpected XML payload {}", getSerialNumber(), iq.payload);
+ }
+ }
+ } catch (DataParsingException e) {
+ listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
+ }
+ } else if (iqRequest instanceof ErrorIQ) {
+ StanzaError error = ((ErrorIQ) iqRequest).getError();
+ logger.trace("{}: Got error response {}", getSerialNumber(), error);
+ listener.onEventStreamFailure(EcovacsXmppDevice.this,
+ new XMPPException.XMPPErrorException(iqRequest, error));
+ }
+ return null;
+ }
+ }
+
+ private static class CommandResponseHolder {
+ @Nullable
+ String response;
+ }
+
+ private static class DeviceCommandIQ extends IQ {
+ static final String TAG_NAME = "query";
+ static final String NAMESPACE = "com:ctl";
+
+ private final String payload;
+ final String id;
+
+ // request
+ public DeviceCommandIQ(IotDeviceCommand> cmd, Jid from, Jid to)
+ throws ParserConfigurationException, TransformerException {
+ super(TAG_NAME, NAMESPACE);
+ setType(Type.set);
+ setTo(to);
+ setFrom(from);
+
+ this.id = createRequestId();
+ this.payload = cmd.getXmlPayload(id);
+ }
+
+ // response
+ public DeviceCommandIQ(@Nullable String id, String payload) {
+ super(TAG_NAME, NAMESPACE);
+ this.id = id != null ? id : "";
+ this.payload = payload.replaceAll("\n|\r", "");
+ }
+
+ @Override
+ protected @Nullable IQChildElementXmlStringBuilder getIQChildElementBuilder(
+ @Nullable IQChildElementXmlStringBuilder xml) {
+ if (xml != null) {
+ xml.rightAngleBracket();
+ xml.append(payload);
+ }
+ return xml;
+ }
+
+ private String createRequestId() {
+ // Ecovacs' app uses numbers for request IDs, so better constrain ourselves to that as well
+ int random8DigitNumber = 10000000 + new Random().nextInt(90000000);
+ return Integer.toString(random8DigitNumber);
+ }
+ }
+
+ private static class CommandIQProvider extends IQProvider<@Nullable DeviceCommandIQ> {
+ @Override
+ public @Nullable DeviceCommandIQ parse(@Nullable XmlPullParser parser, int initialDepth) throws Exception {
+ @Nullable
+ DeviceCommandIQ packet = null;
+
+ if (parser == null) {
+ return null;
+ }
+
+ outerloop: while (true) {
+ switch (parser.next()) {
+ case XmlPullParser.START_TAG:
+ if (parser.getDepth() == initialDepth + 1) {
+ String id = parser.getAttributeValue("", "id");
+ String payload = PacketParserUtils.parseElement(parser).toString();
+ packet = new DeviceCommandIQ(id, payload);
+ }
+ break;
+ case XmlPullParser.END_TAG:
+ if (parser.getDepth() == initialDepth) {
+ break outerloop;
+ }
+ break;
+ }
+ }
+
+ return packet;
+ }
+ }
+
+ static {
+ ProviderManager.addIQProvider(DeviceCommandIQ.TAG_NAME, DeviceCommandIQ.NAMESPACE, new CommandIQProvider());
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/JsonReportParser.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/JsonReportParser.java
new file mode 100644
index 0000000000000..0433ea478b49d
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/JsonReportParser.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice.EventListener;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.BatteryReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.ChargeReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.CleanReport;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.json.CleanReportV2;
+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.portal.PortalIotCommandJsonResponse.JsonResponsePayloadWrapper;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.slf4j.Logger;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+class JsonReportParser implements ReportParser {
+ private final EcovacsDevice device;
+ private final EventListener listener;
+ private final Gson gson;
+ private final Logger logger;
+ private String lastFirmwareVersion = "";
+
+ JsonReportParser(EcovacsDevice device, EventListener listener, ProtocolVersion version, Gson gson, Logger logger) {
+ this.device = device;
+ this.listener = listener;
+ this.gson = gson;
+ this.logger = logger;
+ }
+
+ @Override
+ public void handleMessage(String eventName, String payload) throws DataParsingException {
+ JsonResponsePayloadWrapper response;
+ try {
+ response = gson.fromJson(payload, JsonResponsePayloadWrapper.class);
+ } catch (JsonSyntaxException e) {
+ // The onFwBuryPoint-bd_sysinfo sends a JSON array instead of the expected JsonResponsePayloadBody object.
+ // Since we don't do anything with it anyway, just ignore it
+ logger.debug("{}: Got invalid JSON message payload, ignoring: {}", device.getSerialNumber(), payload, e);
+ response = null;
+ }
+ if (response == null) {
+ return;
+ }
+ if (!lastFirmwareVersion.equals(response.header.firmwareVersion)) {
+ lastFirmwareVersion = response.header.firmwareVersion;
+ listener.onFirmwareVersionChanged(device, lastFirmwareVersion);
+ }
+ if (eventName.startsWith("on")) {
+ eventName = eventName.substring(2);
+ } else if (eventName.startsWith("report")) {
+ eventName = eventName.substring(6);
+ }
+ switch (eventName) {
+ case "battery": {
+ BatteryReport report = payloadAs(response, BatteryReport.class);
+ listener.onBatteryLevelUpdated(device, report.percent);
+ break;
+ }
+ case "chargestate": {
+ ChargeReport report = payloadAs(response, ChargeReport.class);
+ listener.onChargingStateUpdated(device, report.isCharging != 0);
+ break;
+ }
+ case "cleaninfo": {
+ CleanReport report = payloadAs(response, CleanReport.class);
+ CleanMode mode = report.determineCleanMode(gson);
+ if (mode == null) {
+ throw new DataParsingException("Could not get clean mode from response " + payload);
+ }
+ String area = report.cleanState != null ? report.cleanState.areaDefinition : null;
+ handleCleanModeChange(mode, area);
+ break;
+ }
+ case "cleaninfo_v2": {
+ CleanReportV2 report = payloadAs(response, CleanReportV2.class);
+ CleanMode mode = report.determineCleanMode(gson);
+ if (mode == null) {
+ throw new DataParsingException("Could not get clean mode from response " + payload);
+ }
+ String area = report.cleanState != null && report.cleanState.content != null
+ ? report.cleanState.content.areaDefinition
+ : null;
+ handleCleanModeChange(mode, area);
+ break;
+ }
+ case "error": {
+ ErrorReport report = payloadAs(response, ErrorReport.class);
+ for (Integer code : report.errorCodes) {
+ listener.onErrorReported(device, code);
+ }
+ }
+ case "stats": {
+ StatsReport report = payloadAs(response, StatsReport.class);
+ listener.onCleaningStatsUpdated(device, report.area, report.timeInSeconds);
+ break;
+ }
+ case "waterinfo": {
+ WaterInfoReport report = payloadAs(response, WaterInfoReport.class);
+ listener.onWaterSystemPresentUpdated(device, report.waterPlatePresent != 0);
+ break;
+ }
+ // more possible events (unused for now):
+ // - "evt" -> EventReport
+ // - "lifespan" -> ComponentLifeSpanReport
+ // - "speed" -> SpeedReport
+ }
+ }
+
+ private void handleCleanModeChange(CleanMode mode, @Nullable String areaDefinition) {
+ if (mode == CleanMode.CUSTOM_AREA) {
+ logger.debug("{}: Custom area cleaning stated with area definition {}", device.getSerialNumber(),
+ areaDefinition);
+ }
+ listener.onCleaningModeUpdated(device, mode, Optional.ofNullable(areaDefinition));
+ }
+
+ private T payloadAs(JsonResponsePayloadWrapper response, Class clazz) throws DataParsingException {
+ @Nullable
+ T payload = gson.fromJson(response.body.payload, clazz);
+ if (payload == null) {
+ throw new DataParsingException("Null payload in response " + response);
+ }
+ return payload;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/PortalTodo.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/PortalTodo.java
new file mode 100644
index 0000000000000..13987989ff0aa
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/PortalTodo.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public enum PortalTodo {
+ @SerializedName("GetDeviceList")
+ GET_DEVICE_LIST,
+ @SerializedName("loginByItToken")
+ LOGIN_BY_TOKEN;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ProtocolVersion.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ProtocolVersion.java
new file mode 100644
index 0000000000000..10486b323caae
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ProtocolVersion.java
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public enum ProtocolVersion {
+ @SerializedName("xml")
+ XML,
+ @SerializedName("json")
+ JSON,
+ @SerializedName("json_v2")
+ JSON_V2
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ReportParser.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ReportParser.java
new file mode 100644
index 0000000000000..53db9eefa2344
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ReportParser.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public interface ReportParser {
+ void handleMessage(String eventName, String payload) throws DataParsingException;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/XmlReportParser.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/XmlReportParser.java
new file mode 100644
index 0000000000000..be9f7a0b7ec8f
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/XmlReportParser.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice.EventListener;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.CleaningInfo;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.DeviceInfo;
+import org.openhab.binding.ecovacs.internal.api.impl.dto.response.deviceapi.xml.WaterSystemInfo;
+import org.openhab.binding.ecovacs.internal.api.model.ChargeMode;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.slf4j.Logger;
+import org.w3c.dom.Node;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+class XmlReportParser implements ReportParser {
+ private final EcovacsDevice device;
+ private final EventListener listener;
+ private final Gson gson;
+ private final Logger logger;
+
+ XmlReportParser(EcovacsDevice device, EventListener listener, Gson gson, Logger logger) {
+ this.device = device;
+ this.listener = listener;
+ this.gson = gson;
+ this.logger = logger;
+ }
+
+ @Override
+ public void handleMessage(String eventName, String payload) throws DataParsingException {
+ switch (eventName.toLowerCase()) {
+ case "batteryinfo":
+ listener.onBatteryLevelUpdated(device, DeviceInfo.parseBatteryInfo(payload));
+ break;
+ case "chargestate": {
+ ChargeMode mode = DeviceInfo.parseChargeInfo(payload, gson);
+ if (mode == ChargeMode.RETURNING) {
+ listener.onCleaningModeUpdated(device, CleanMode.RETURNING, Optional.empty());
+ }
+ listener.onChargingStateUpdated(device, mode == ChargeMode.CHARGING);
+ break;
+ }
+ case "cleanreport": {
+ CleaningInfo.CleanStateInfo info = CleaningInfo.parseCleanStateInfo(payload, gson);
+ if (info.mode == CleanMode.CUSTOM_AREA) {
+ logger.debug("{}: Custom area cleaning stated with area definition {}", device.getSerialNumber(),
+ info.areaDefinition);
+ }
+ listener.onCleaningModeUpdated(device, info.mode, info.areaDefinition);
+ // Full report:
+ //
+ break;
+ }
+ case "cleanrptbgdata": {
+ Node fromChargerNode = XPathUtils.getFirstXPathMatch(payload, "//@IsFrmCharger");
+ if ("1".equals(fromChargerNode.getNodeValue())) {
+ // Device just started cleaning, but likely won't send us a ChargeState report,
+ // so update charging state from here
+ listener.onChargingStateUpdated(device, false);
+ }
+ // Full report:
+ //
+ break;
+ }
+ case "cleanst": {
+ String area = XPathUtils.getFirstXPathMatch(payload, "//@a").getNodeValue();
+ String duration = XPathUtils.getFirstXPathMatch(payload, "//@l").getNodeValue();
+ listener.onCleaningStatsUpdated(device, Integer.valueOf(area), Integer.valueOf(duration));
+ break;
+ }
+ case "error":
+ DeviceInfo.parseErrorInfo(payload).ifPresent(errorCode -> {
+ listener.onErrorReported(device, errorCode);
+ });
+ break;
+ case "waterboxinfo":
+ listener.onWaterSystemPresentUpdated(device, WaterSystemInfo.parseWaterBoxInfo(payload));
+ break;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequest.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequest.java
new file mode 100644
index 0000000000000..a7b364be74ece
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequest.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2023 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.request.portal;
+
+import org.openhab.binding.ecovacs.internal.api.impl.PortalTodo;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class PortalAuthRequest {
+
+ @SerializedName("todo")
+ final PortalTodo todo;
+
+ @SerializedName("userid")
+ final String userId;
+
+ @SerializedName("auth")
+ final PortalAuthRequestParameter auth;
+
+ public PortalAuthRequest(PortalTodo todo, PortalAuthRequestParameter auth) {
+ this.todo = todo;
+ this.userId = auth.userId;
+ this.auth = auth;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequestParameter.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequestParameter.java
new file mode 100644
index 0000000000000..036d7b0986a06
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequestParameter.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010-2023 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.request.portal;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class PortalAuthRequestParameter {
+
+ @SerializedName("with")
+ final String with;
+
+ @SerializedName("userid")
+ final String userId;
+
+ @SerializedName("realm")
+ final String realm;
+
+ @SerializedName("token")
+ final String token;
+
+ @SerializedName("resource")
+ final String resource;
+
+ public PortalAuthRequestParameter(String with, String userid, String realm, String token, String resource) {
+ this.with = with;
+ this.userId = userid;
+ this.realm = realm;
+ this.token = token;
+ this.resource = resource;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalCleanLogsRequest.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalCleanLogsRequest.java
new file mode 100644
index 0000000000000..0d41c9de2388f
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalCleanLogsRequest.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2010-2023 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.request.portal;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class PortalCleanLogsRequest {
+
+ @SerializedName("auth")
+ final PortalAuthRequestParameter auth;
+
+ @SerializedName("td")
+ final String commandName = "GetCleanLogs";
+
+ @SerializedName("did")
+ final String targetDeviceId;
+
+ @SerializedName("resource")
+ final String targetResource;
+
+ public PortalCleanLogsRequest(PortalAuthRequestParameter auth, String targetDeviceId, String targetResource) {
+ this.auth = auth;
+ this.targetDeviceId = targetDeviceId;
+ this.targetResource = targetResource;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotCommandRequest.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotCommandRequest.java
new file mode 100644
index 0000000000000..ef69996a52eef
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotCommandRequest.java
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2010-2023 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.request.portal;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class PortalIotCommandRequest {
+
+ @SerializedName("auth")
+ final PortalAuthRequestParameter auth;
+
+ @SerializedName("cmdName")
+ final String commandName;
+
+ @SerializedName("payload")
+ final Object payload;
+
+ @SerializedName("payloadType")
+ final String payloadType;
+
+ @SerializedName("td")
+ final String td = "q";
+
+ @SerializedName("toId")
+ final String targetDeviceId;
+
+ @SerializedName("toRes")
+ final String targetResource;
+
+ @SerializedName("toType")
+ final String targetClass;
+
+ public PortalIotCommandRequest(PortalAuthRequestParameter auth, String commandName, Object payload,
+ String targetDeviceId, String targetResource, String targetClass, boolean json) {
+ this.auth = auth;
+ this.commandName = commandName;
+ this.payload = payload;
+ this.targetDeviceId = targetDeviceId;
+ this.targetResource = targetResource;
+ this.targetClass = targetClass;
+ this.payloadType = json ? "j" : "x";
+ }
+
+ public static class JsonPayloadHeader {
+ @SerializedName("pri")
+ public final int pri = 1;
+ @SerializedName("ts")
+ public final long timestamp;
+ @SerializedName("tzm")
+ public final int tzm = 480;
+ @SerializedName("ver")
+ public final String version = "0.0.50";
+
+ public JsonPayloadHeader() {
+ timestamp = System.currentTimeMillis();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotProductRequest.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotProductRequest.java
new file mode 100644
index 0000000000000..9a159094b07fb
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotProductRequest.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2023 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.request.portal;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class PortalIotProductRequest {
+
+ @SerializedName("todo")
+ final String todo = "";
+
+ @SerializedName("channel")
+ final String channel = "";
+
+ @SerializedName("auth")
+ final PortalAuthRequestParameter auth;
+
+ public PortalIotProductRequest(PortalAuthRequestParameter auth) {
+ this.auth = auth;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalLoginRequest.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalLoginRequest.java
new file mode 100644
index 0000000000000..33c6edff5afb6
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalLoginRequest.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2023 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.request.portal;
+
+import org.openhab.binding.ecovacs.internal.api.impl.PortalTodo;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class PortalLoginRequest {
+
+ @SerializedName("todo")
+ final PortalTodo todo;
+
+ @SerializedName("country")
+ final String country;
+
+ @SerializedName("last")
+ final String last;
+
+ @SerializedName("org")
+ final String org;
+
+ @SerializedName("resource")
+ final String resource;
+
+ @SerializedName("realm")
+ final String realm;
+
+ @SerializedName("token")
+ final String token;
+
+ @SerializedName("userid")
+ final String userId;
+
+ @SerializedName("edition")
+ final String edition;
+
+ public PortalLoginRequest(PortalTodo todo, String country, String last, String org, String resource, String realm,
+ String token, String userId, String edition) {
+ this.todo = todo;
+ this.country = country;
+ this.last = last;
+ this.org = org;
+ this.resource = resource;
+ this.realm = realm;
+ this.token = token;
+ this.userId = userId;
+ this.edition = edition;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/BatteryReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/BatteryReport.java
new file mode 100644
index 0000000000000..106107bea1627
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/BatteryReport.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class BatteryReport {
+ @SerializedName("value")
+ public int percent;
+ @SerializedName("isLow")
+ public int batteryIsLow;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CachedMapInfoReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CachedMapInfoReport.java
new file mode 100644
index 0000000000000..ccc09a3326b21
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CachedMapInfoReport.java
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class CachedMapInfoReport {
+ @SerializedName("enable")
+ public int enable;
+
+ @SerializedName("info")
+ public List mapInfos;
+
+ public static class CachedMapInfo {
+ @SerializedName("mid")
+ public String mapId;
+ public int index;
+ public int status;
+ @SerializedName("using")
+ public int used;
+ public int built;
+ public String name;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ChargeReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ChargeReport.java
new file mode 100644
index 0000000000000..026b966f35ce1
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ChargeReport.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class ChargeReport {
+ @SerializedName("isCharging")
+ public int isCharging;
+ @SerializedName("mode")
+ public String mode; // slot, ...?
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReport.java
new file mode 100644
index 0000000000000..faed6211fb50c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReport.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2010-2023 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 CleanReport {
+ @SerializedName("trigger")
+ public String trigger; // app, workComplete, ...?
+ @SerializedName("state")
+ public String state;
+ @SerializedName("cleanState")
+ public CleanStateReport cleanState;
+
+ public static class CleanStateReport {
+ @SerializedName("router")
+ public String router; // plan, ...?
+ @SerializedName("type")
+ public String type;
+ @SerializedName("motionState")
+ public String motionState;
+ @SerializedName("content")
+ public String areaDefinition;
+ }
+
+ public CleanMode determineCleanMode(Gson gson) {
+ final String modeValue;
+ if (cleanState != null) {
+ if ("working".equals(cleanState.motionState)) {
+ modeValue = cleanState.type;
+ } else {
+ modeValue = cleanState.motionState;
+ }
+ } else {
+ modeValue = state;
+ }
+ return gson.fromJson(modeValue, CleanMode.class);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReportV2.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReportV2.java
new file mode 100644
index 0000000000000..4d87fd5ba6273
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReportV2.java
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2023 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 CleanReportV2 {
+ @SerializedName("trigger")
+ public String trigger; // app, workComplete, ...?
+ @SerializedName("state")
+ public String state;
+ @SerializedName("cleanState")
+ public CleanStateReportV2 cleanState;
+
+ public static class CleanStateReportV2 {
+ @SerializedName("router")
+ public String router; // plan, ...?
+ @SerializedName("motionState")
+ public String motionState;
+ @SerializedName("content")
+ public CleanStateReportV2Content content;
+ }
+
+ public static class CleanStateReportV2Content {
+ @SerializedName("type")
+ public String type;
+ @SerializedName("value")
+ public String areaDefinition;
+ }
+
+ public CleanMode determineCleanMode(Gson gson) {
+ final String modeValue;
+ if ("clean".equals(state) && cleanState != null) {
+ if ("working".equals(cleanState.motionState)) {
+ modeValue = cleanState.content.type;
+ } else {
+ modeValue = cleanState.motionState;
+ }
+ } else {
+ modeValue = state;
+ }
+ return gson.fromJson(modeValue, CleanMode.class);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ComponentLifeSpanReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ComponentLifeSpanReport.java
new file mode 100644
index 0000000000000..78c74b127a9c8
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ComponentLifeSpanReport.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class ComponentLifeSpanReport {
+ @SerializedName("type")
+ public String type;
+
+ @SerializedName("left")
+ public int left;
+
+ @SerializedName("total")
+ public int total;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/DefaultCleanCountReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/DefaultCleanCountReport.java
new file mode 100644
index 0000000000000..81edf38f559ad
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/DefaultCleanCountReport.java
@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class DefaultCleanCountReport {
+ public int count;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EnabledStateReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EnabledStateReport.java
new file mode 100644
index 0000000000000..ef75864683c31
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EnabledStateReport.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class EnabledStateReport {
+ @SerializedName("enable")
+ public int enabled;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ErrorReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ErrorReport.java
new file mode 100644
index 0000000000000..cfd96caa6528b
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ErrorReport.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class ErrorReport {
+ @SerializedName("code")
+ public List errorCodes;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EventReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EventReport.java
new file mode 100644
index 0000000000000..81ba730c70288
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EventReport.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class EventReport {
+ @SerializedName("code")
+ public int eventCode;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/MapSetReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/MapSetReport.java
new file mode 100644
index 0000000000000..d6ce3a0f9bf98
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/MapSetReport.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2023 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 java.util.List;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class MapSetReport {
+ public String type;
+ public int count;
+ @SerializedName("mid")
+ public String mapId;
+ @SerializedName("msid")
+ public String mapSetId;
+ public List subsets;
+
+ public static class MapSubSetInfo {
+ @SerializedName("mssid")
+ public String id;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/NetworkInfoReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/NetworkInfoReport.java
new file mode 100644
index 0000000000000..0123f7c65cb9c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/NetworkInfoReport.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2010-2023 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;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class NetworkInfoReport {
+ public String ip;
+ public String mac;
+ public String ssid;
+ public String rssi;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SleepReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SleepReport.java
new file mode 100644
index 0000000000000..955745f29f2cc
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SleepReport.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class SleepReport {
+ @SerializedName("enable")
+ public int sleeping;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SpeedReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SpeedReport.java
new file mode 100644
index 0000000000000..d81da0c0a5f4e
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SpeedReport.java
@@ -0,0 +1,23 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class SpeedReport {
+ @SerializedName("speed")
+ public int speedLevel;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/StatsReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/StatsReport.java
new file mode 100644
index 0000000000000..0b38aa0a93f37
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/StatsReport.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class StatsReport {
+ @SerializedName("area")
+ public int area;
+ @SerializedName("time")
+ public int timeInSeconds;
+ @SerializedName("cid")
+ public String cid;
+ @SerializedName("start")
+ public long startTimestamp;
+ @SerializedName("type")
+ public String type; // auto, ... ?
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/WaterInfoReport.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/WaterInfoReport.java
new file mode 100644
index 0000000000000..412f79eea2087
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/WaterInfoReport.java
@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class WaterInfoReport {
+ @SerializedName("enable")
+ public int waterPlatePresent;
+ @SerializedName("amount")
+ public int waterAmount;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/CleaningInfo.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/CleaningInfo.java
new file mode 100644
index 0000000000000..a4dce40e367dc
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/CleaningInfo.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright (c) 2010-2023 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.xml;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.model.SuctionPower;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.w3c.dom.Node;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class CleaningInfo {
+ public static class CleanStateInfo {
+ public final CleanMode mode;
+ public final Optional areaDefinition;
+
+ CleanStateInfo(CleanMode mode) {
+ this(mode, Optional.empty());
+ }
+
+ CleanStateInfo(CleanMode mode, Optional areaDefinition) {
+ this.mode = mode;
+ this.areaDefinition = areaDefinition;
+ }
+ }
+
+ public static CleanStateInfo parseCleanStateInfo(String xml, Gson gson) throws DataParsingException {
+ String stateString = XPathUtils.getFirstXPathMatchOpt(xml, "//clean/@st").map(n -> n.getNodeValue()).orElse("");
+
+ if ("h".equals(stateString)) {
+ return new CleanStateInfo(CleanMode.STOP);
+ } else if ("p".equals(stateString)) {
+ return new CleanStateInfo(CleanMode.PAUSE);
+ } else {
+ String modeString = XPathUtils.getFirstXPathMatch(xml, "//clean/@type").getNodeValue();
+ CleanMode parsedMode = gson.fromJson(modeString, CleanMode.class);
+ if (parsedMode == CleanMode.SPOT_AREA) {
+ Optional pointOpt = XPathUtils.getFirstXPathMatchOpt(xml, "//clean/@p");
+ if (pointOpt.isPresent()) {
+ return new CleanStateInfo(CleanMode.CUSTOM_AREA, pointOpt.map(n -> n.getNodeValue()));
+ }
+ Optional midOpt = XPathUtils.getFirstXPathMatchOpt(xml, "//clean/@mid");
+ return new CleanStateInfo(CleanMode.SPOT_AREA, midOpt.map(n -> n.getNodeValue()));
+ }
+ if (parsedMode != null) {
+ return new CleanStateInfo(parsedMode);
+ }
+ }
+ throw new DataParsingException("Unexpected clean state report: " + xml);
+ }
+
+ public static SuctionPower parseCleanSpeedInfo(String xml, Gson gson) throws DataParsingException {
+ String levelString = XPathUtils.getFirstXPathMatch(xml, "//@speed").getNodeValue();
+ SuctionPower level = gson.fromJson(levelString, SuctionPower.class);
+ if (level == null) {
+ throw new DataParsingException("Could not parse power level " + levelString);
+ }
+ return level;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/DeviceInfo.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/DeviceInfo.java
new file mode 100644
index 0000000000000..9ac668558bbe7
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/DeviceInfo.java
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2010-2023 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.xml;
+
+import java.util.Optional;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.model.ChargeMode;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.w3c.dom.Node;
+
+import com.google.gson.Gson;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceInfo {
+ private static final Set ERROR_ATTR_NAMES = Set.of("code", "error", "errno", "errs");
+
+ public static int parseBatteryInfo(String xml) throws DataParsingException {
+ Node batteryAttr = XPathUtils.getFirstXPathMatch(xml, "//battery/@power");
+ return Integer.valueOf(batteryAttr.getNodeValue());
+ }
+
+ public static ChargeMode parseChargeInfo(String xml, Gson gson) throws DataParsingException {
+ String modeString = XPathUtils.getFirstXPathMatch(xml, "//charge/@type").getNodeValue();
+ ChargeMode mode = gson.fromJson(modeString, ChargeMode.class);
+ if (mode == null) {
+ throw new IllegalArgumentException("Could not parse charge mode " + modeString);
+ }
+ return mode;
+ }
+
+ public static Optional parseErrorInfo(String xml) throws DataParsingException {
+ for (String attr : ERROR_ATTR_NAMES) {
+ Optional node = XPathUtils.getFirstXPathMatchOpt(xml, "//@" + attr);
+ if (node.isPresent()) {
+ try {
+ String value = node.get().getNodeValue();
+ return value.isEmpty() ? Optional.empty() : Optional.of(Integer.valueOf(value));
+ } catch (NumberFormatException e) {
+ throw new DataParsingException(e);
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ public static int parseComponentLifespanInfo(String xml) throws DataParsingException {
+ Optional value = nodeValueToInt(xml, "value");
+ Optional total = nodeValueToInt(xml, "total");
+ Optional left = nodeValueToInt(xml, "left");
+ if (value.isPresent() && total.isPresent()) {
+ return (int) Math.round(100.0 * value.get() / total.get());
+ } else if (value.isPresent()) {
+ return (int) Math.round(0.01 * value.get());
+ } else if (left.isPresent() && total.isPresent()) {
+ return (int) Math.round(100.0 * left.get() / total.get());
+ } else if (left.isPresent()) {
+ return (int) Math.round((double) left.get() / 60.0);
+ }
+ return 0;
+ }
+
+ public static boolean parseEnabledStateInfo(String xml) throws DataParsingException {
+ String value = XPathUtils.getFirstXPathMatch(xml, "//@on").getNodeValue();
+ try {
+ return Integer.valueOf(value) != 0;
+ } catch (NumberFormatException e) {
+ throw new DataParsingException(e);
+ }
+ }
+
+ private static Optional nodeValueToInt(String xml, String attrName) throws DataParsingException {
+ try {
+ return XPathUtils.getFirstXPathMatchOpt(xml, "//ctl/@" + attrName)
+ .map(n -> Integer.valueOf(n.getNodeValue()));
+ } catch (NumberFormatException e) {
+ throw new DataParsingException(e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/WaterSystemInfo.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/WaterSystemInfo.java
new file mode 100644
index 0000000000000..2116b338fefa6
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/WaterSystemInfo.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2023 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.xml;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.ecovacs.internal.api.model.MoppingWaterAmount;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
+import org.w3c.dom.Node;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class WaterSystemInfo {
+ /**
+ * @return Whether water system is present
+ */
+ public static boolean parseWaterBoxInfo(String xml) throws DataParsingException {
+ Node node = XPathUtils.getFirstXPathMatch(xml, "//@on");
+ return Integer.valueOf(node.getNodeValue()) != 0;
+ }
+
+ public static MoppingWaterAmount parseWaterPermeabilityInfo(String xml) throws DataParsingException {
+ Node node = XPathUtils.getFirstXPathMatch(xml, "//@v");
+ try {
+ return MoppingWaterAmount.fromApiValue(Integer.valueOf(node.getNodeValue()));
+ } catch (NumberFormatException e) {
+ throw new DataParsingException(e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AccessData.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AccessData.java
new file mode 100644
index 0000000000000..9e3bec464390e
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AccessData.java
@@ -0,0 +1,89 @@
+/**
+ * Copyright (c) 2010-2023 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.main;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class AccessData {
+
+ @SerializedName("uid")
+ private final String uid;
+
+ @SerializedName("accessToken")
+ private final String accessToken;
+
+ @SerializedName("userName")
+ private final String userName;
+
+ @SerializedName("email")
+ private final String email;
+
+ @SerializedName("mobile")
+ private final String mobile;
+
+ @SerializedName("isNew")
+ private final boolean isNew;
+
+ @SerializedName("loginName")
+ private final String loginName;
+
+ @SerializedName("ucUid")
+ private final String ucUid;
+
+ public AccessData(String uid, String accessToken, String userName, String email, String mobile, boolean isNew,
+ String loginName, String ucUid) {
+ this.uid = uid;
+ this.accessToken = accessToken;
+ this.userName = userName;
+ this.email = email;
+ this.mobile = mobile;
+ this.isNew = isNew;
+ this.loginName = loginName;
+ this.ucUid = ucUid;
+ }
+
+ public String getUid() {
+ return uid;
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public String getUserName() {
+ return userName;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public String getMobile() {
+ return mobile;
+ }
+
+ public boolean isNew() {
+ return isNew;
+ }
+
+ public String getLoginName() {
+ return loginName;
+ }
+
+ public String getUcUid() {
+ return ucUid;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AuthCode.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AuthCode.java
new file mode 100644
index 0000000000000..506a4f424b231
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AuthCode.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2023 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.main;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class AuthCode {
+
+ @SerializedName("ecovacsUid")
+ private final String ecovacsUid;
+
+ @SerializedName("authCode")
+ private final String authCode;
+
+ public AuthCode(String ecovacsUid, String authCode) {
+ this.ecovacsUid = ecovacsUid;
+ this.authCode = authCode;
+ }
+
+ public String getEcovacsUid() {
+ return ecovacsUid;
+ }
+
+ public String getAuthCode() {
+ return authCode;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/ResponseWrapper.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/ResponseWrapper.java
new file mode 100644
index 0000000000000..671206262e5dd
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/ResponseWrapper.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2023 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.main;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class ResponseWrapper {
+ @SerializedName("code")
+ private final String code;
+
+ @SerializedName("time")
+ private final String time;
+
+ @SerializedName("msg")
+ private final String message;
+
+ @SerializedName("data")
+ private final T data;
+
+ @SerializedName("success")
+ private final boolean success;
+
+ public ResponseWrapper(String code, String time, String message, T data, boolean success) {
+ this.code = code;
+ this.time = time;
+ this.message = message;
+ this.data = data;
+ this.success = success;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getTime() {
+ return time;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalIotCommandResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalIotCommandResponse.java
new file mode 100644
index 0000000000000..339c88c3c139c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalIotCommandResponse.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class AbstractPortalIotCommandResponse {
+ @SerializedName("ret")
+ private final String result;
+
+ @SerializedName("errno")
+ private final int errorCode;
+ @SerializedName("error")
+ private final String errorMessage;
+
+ // unused field: 'id' (string)
+
+ public AbstractPortalIotCommandResponse(String result, int errorCode, String errorMessage) {
+ this.result = result;
+ this.errorCode = errorCode;
+ this.errorMessage = errorMessage;
+ }
+
+ public boolean wasSuccessful() {
+ return "ok".equals(result);
+ }
+
+ public boolean failedDueToAuthProblem() {
+ return "fail".equals(result) && errorMessage != null && errorMessage.toLowerCase().contains("auth error");
+ }
+
+ public String getErrorMessage() {
+ if (wasSuccessful()) {
+ return null;
+ }
+ return "result=" + result + ", errno=" + errorCode + ", error=" + errorMessage;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalResponse.java
new file mode 100644
index 0000000000000..693655ef4fbff
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalResponse.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public abstract class AbstractPortalResponse {
+ @SerializedName("result")
+ private final String result;
+
+ // unused field: 'todo' (string)
+
+ protected AbstractPortalResponse(String result) {
+ this.result = result;
+ }
+
+ public boolean wasSuccessful() {
+ return "ok".equals(result);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Device.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Device.java
new file mode 100644
index 0000000000000..44f622d24bbf7
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Device.java
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class Device {
+ @SerializedName("did")
+ private final String did;
+
+ @SerializedName("name")
+ private final String name;
+
+ @SerializedName("class")
+ private final String deviceClass;
+
+ @SerializedName("resource")
+ private final String resource;
+
+ @SerializedName("nick")
+ private final String nick;
+
+ @SerializedName("company")
+ private final String company;
+
+ @SerializedName("bindTs")
+ private final long bindTs;
+
+ @SerializedName("service")
+ private final Service service;
+
+ public Device(String did, String name, String deviceClass, String resource, String nick, String company,
+ long bindTs, Service service) {
+ this.did = did;
+ this.name = name;
+ this.deviceClass = deviceClass;
+ this.resource = resource;
+ this.nick = nick;
+ this.company = company;
+ this.bindTs = bindTs;
+ this.service = service;
+ }
+
+ public String getDid() {
+ return did;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getDeviceClass() {
+ return deviceClass;
+ }
+
+ public String getResource() {
+ return resource;
+ }
+
+ public String getNick() {
+ return nick;
+ }
+
+ public String getCompany() {
+ return company;
+ }
+
+ public long getBindTs() {
+ return bindTs;
+ }
+
+ public Service getService() {
+ return service;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/IotProduct.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/IotProduct.java
new file mode 100644
index 0000000000000..cd0f4315514cb
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/IotProduct.java
@@ -0,0 +1,102 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class IotProduct {
+ @SerializedName("classid")
+ private final String classId;
+
+ @SerializedName("product")
+ private final ProductDefinition productDef;
+
+ public IotProduct(String classId, ProductDefinition productDef) {
+ this.classId = classId;
+ this.productDef = productDef;
+ }
+
+ public String getClassId() {
+ return classId;
+ }
+
+ public ProductDefinition getDefinition() {
+ return productDef;
+ }
+
+ public static class ProductDefinition {
+ @SerializedName("_id")
+ public final String id;
+
+ @SerializedName("materialNo")
+ public final String materialNumber;
+
+ @SerializedName("name")
+ public final String name;
+
+ @SerializedName("icon")
+ public final String icon;
+
+ @SerializedName("iconUrl")
+ public final String iconUrl;
+
+ @SerializedName("model")
+ public final String model;
+
+ @SerializedName("UILogicId")
+ public final String uiLogicId;
+
+ @SerializedName("ota")
+ public final boolean otaCapable;
+
+ @SerializedName("supportType")
+ public final SupportFlags supportFlags;
+
+ public ProductDefinition(String id, String materialNumber, String name, String icon, String iconUrl,
+ String model, String uiLogicId, boolean otaCapable, SupportFlags supportFlags) {
+ this.id = id;
+ this.materialNumber = materialNumber;
+ this.name = name;
+ this.icon = icon;
+ this.iconUrl = iconUrl;
+ this.model = model;
+ this.uiLogicId = uiLogicId;
+ this.otaCapable = otaCapable;
+ this.supportFlags = supportFlags;
+ }
+ }
+
+ public static class SupportFlags {
+ @SerializedName("share")
+ public final boolean canShare;
+
+ @SerializedName("tmjl")
+ public final boolean tmjl; // ???
+
+ @SerializedName("assistant")
+ public final boolean canUseAssistant;
+
+ @SerializedName("alexa")
+ public final boolean canUseAlexa;
+
+ public SupportFlags(boolean share, boolean tmjl, boolean assistant, boolean alexa) {
+ this.canShare = share;
+ this.tmjl = tmjl;
+ this.canUseAssistant = assistant;
+ this.canUseAlexa = alexa;
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalCleanLogsResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalCleanLogsResponse.java
new file mode 100644
index 0000000000000..93d5dafdc94b2
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalCleanLogsResponse.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2023 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 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 records;
+
+ @SerializedName("ret")
+ final String result;
+
+ PortalCleanLogsResponse(String result, List records) {
+ this.result = result;
+ this.records = records;
+ }
+
+ public boolean wasSuccessful() {
+ return "ok".equals(result);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalDeviceResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalDeviceResponse.java
new file mode 100644
index 0000000000000..ac49dc2751707
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalDeviceResponse.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2023 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 Johannes Ptaszyk - Initial contribution
+ */
+public class PortalDeviceResponse extends AbstractPortalResponse {
+
+ @SerializedName("devices")
+ private final List devices;
+
+ public PortalDeviceResponse(String result, List devices) {
+ super(result);
+ this.devices = devices;
+ }
+
+ public List getDevices() {
+ return devices;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandJsonResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandJsonResponse.java
new file mode 100644
index 0000000000000..29627a85108fc
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandJsonResponse.java
@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2010-2023 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.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class PortalIotCommandJsonResponse extends AbstractPortalIotCommandResponse {
+ @SerializedName("resp")
+ public final JsonElement response;
+
+ public PortalIotCommandJsonResponse(String result, JsonElement response, int errorCode, String errorMessage) {
+ super(result, errorCode, errorMessage);
+ this.response = response;
+ }
+
+ public T getResponsePayloadAs(Gson gson, Class clazz) throws DataParsingException {
+ try {
+ JsonElement payloadRaw = getResponsePayload(gson);
+ @Nullable
+ T payload = gson.fromJson(payloadRaw, clazz);
+ if (payload == null) {
+ throw new DataParsingException("Empty JSON payload");
+ }
+ return payload;
+ } catch (JsonSyntaxException e) {
+ throw new DataParsingException(e);
+ }
+ }
+
+ public JsonElement getResponsePayload(Gson gson) throws DataParsingException {
+ try {
+ @Nullable
+ JsonResponsePayloadWrapper wrapper = gson.fromJson(response, JsonResponsePayloadWrapper.class);
+ if (wrapper == null) {
+ throw new DataParsingException("Empty JSON payload");
+ }
+ return wrapper.body.payload;
+ } catch (JsonSyntaxException e) {
+ throw new DataParsingException(e);
+ }
+ }
+
+ public static class JsonPayloadHeader {
+ @SerializedName("pri")
+ public int pri;
+ @SerializedName("ts")
+ public long timestamp;
+ @SerializedName("tzm")
+ public int tzm;
+ @SerializedName("fwVer")
+ public String firmwareVersion;
+ @SerializedName("hwVer")
+ public String hardwareVersion;
+ }
+
+ public static class JsonResponsePayloadWrapper {
+ @SerializedName("header")
+ public JsonPayloadHeader header;
+ @SerializedName("body")
+ public JsonResponsePayloadBody body;
+ }
+
+ public static class JsonResponsePayloadBody {
+ @SerializedName("code")
+ public int code;
+ @SerializedName("msg")
+ public String message;
+ @SerializedName("data")
+ public JsonElement payload;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandXmlResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandXmlResponse.java
new file mode 100644
index 0000000000000..6fff6dc16a841
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandXmlResponse.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+public class PortalIotCommandXmlResponse extends AbstractPortalIotCommandResponse {
+ @SerializedName("resp")
+ private final String responseXml;
+
+ public PortalIotCommandXmlResponse(String result, String responseXml, int errorCode, String errorMessage) {
+ super(result, errorCode, errorMessage);
+ this.responseXml = responseXml;
+ }
+
+ public String getResponsePayloadXml() {
+ return responseXml != null ? responseXml.replaceAll("\n|\r", "") : null;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotProductResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotProductResponse.java
new file mode 100644
index 0000000000000..6d45fdad31c01
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotProductResponse.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2023 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 PortalIotProductResponse {
+ @SerializedName("data")
+ private final List products;
+
+ // unused field: 'code' (integer)
+
+ public PortalIotProductResponse(List products) {
+ this.products = products;
+ }
+
+ public List getProducts() {
+ return products;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalLoginResponse.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalLoginResponse.java
new file mode 100644
index 0000000000000..26bed8dbb463d
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalLoginResponse.java
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class PortalLoginResponse extends AbstractPortalResponse {
+
+ @SerializedName("userId")
+ private final String userId;
+
+ @SerializedName("resource")
+ private final String resource;
+
+ @SerializedName("token")
+ private final String token;
+
+ @SerializedName("last")
+ private final String last;
+
+ public PortalLoginResponse(String result, String userId, String resource, String token, String last) {
+ super(result);
+ this.userId = userId;
+ this.resource = resource;
+ this.token = token;
+ this.last = last;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public String getResource() {
+ return resource;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public String getLast() {
+ return last;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Service.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Service.java
new file mode 100644
index 0000000000000..5fa694fdb1820
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Service.java
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2023 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 com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+public class Service {
+
+ @SerializedName("jmq")
+ private final String jmq;
+
+ @SerializedName("mqs")
+ private final String mqs;
+
+ public Service(String jmq, String mqs) {
+ this.jmq = jmq;
+ this.mqs = mqs;
+ }
+
+ public String getJmq() {
+ return jmq;
+ }
+
+ public String getMqs() {
+ return mqs;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/ChargeMode.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/ChargeMode.java
new file mode 100644
index 0000000000000..c8230abf0d85e
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/ChargeMode.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public enum ChargeMode {
+ @SerializedName("go")
+ RETURN,
+ @SerializedName("Going")
+ RETURNING,
+ @SerializedName("SlotCharging")
+ CHARGING,
+ @SerializedName("Idle")
+ IDLE;
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanLogRecord.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanLogRecord.java
new file mode 100644
index 0000000000000..dc8df1d07bfbe
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanLogRecord.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import java.util.Date;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class CleanLogRecord {
+ public final Date timestamp;
+ public final long cleaningDuration;
+ public final int cleanedArea;
+ public final Optional mapImageUrl;
+ public final CleanMode mode;
+
+ public CleanLogRecord(long timestamp, long duration, int area, Optional mapImageUrl, CleanMode mode) {
+ this.timestamp = new Date(timestamp * 1000);
+ this.cleaningDuration = duration;
+ this.cleanedArea = area;
+ this.mapImageUrl = mapImageUrl;
+ this.mode = mode;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanMode.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanMode.java
new file mode 100644
index 0000000000000..07817f6f05276
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanMode.java
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public enum CleanMode {
+ @SerializedName("auto")
+ AUTO,
+ @SerializedName("border")
+ EDGE,
+ @SerializedName("spot")
+ SPOT,
+ @SerializedName(value = "SpotArea", alternate = { "spotArea" })
+ SPOT_AREA,
+ @SerializedName(value = "CustomArea", alternate = { "customArea" })
+ CUSTOM_AREA,
+ @SerializedName("singleRoom")
+ SINGLE_ROOM,
+ @SerializedName("pause")
+ PAUSE,
+ @SerializedName("stop")
+ STOP,
+ @SerializedName(value = "going", alternate = { "goCharging" })
+ RETURNING,
+ @SerializedName("washing")
+ WASHING,
+ @SerializedName("drying")
+ DRYING,
+ @SerializedName("idle")
+ IDLE;
+
+ public boolean isActive() {
+ return this == AUTO || this == EDGE || this == SPOT || this == SPOT_AREA || this == CUSTOM_AREA
+ || this == SINGLE_ROOM;
+ }
+
+ public boolean isIdle() {
+ return this == IDLE || this == DRYING || this == WASHING;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/Component.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/Component.java
new file mode 100644
index 0000000000000..56b9b64145f8c
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/Component.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public enum Component {
+ BRUSH("Brush", "brush"),
+ SIDE_BRUSH("SideBrush", "sideBrush"),
+ DUST_CASE_HEAP("DustCaseHeap", "heap"),
+ UNIT_CARE("" /* not supported in XML */, "unitCare");
+
+ public final String xmlValue;
+ public final String jsonValue;
+
+ private Component(String xmlValue, String jsonValue) {
+ this.xmlValue = xmlValue;
+ this.jsonValue = jsonValue;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/DeviceCapability.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/DeviceCapability.java
new file mode 100644
index 0000000000000..d6dfea0ac5c95
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/DeviceCapability.java
@@ -0,0 +1,54 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public enum DeviceCapability {
+ @SerializedName("mopping_system")
+ MOPPING_SYSTEM,
+ @SerializedName("main_brush")
+ MAIN_BRUSH,
+ @SerializedName("voice_reporting")
+ VOICE_REPORTING,
+ @SerializedName("spot_area_cleaning")
+ SPOT_AREA_CLEANING,
+ @SerializedName("custom_area_cleaning")
+ CUSTOM_AREA_CLEANING,
+ @SerializedName("single_room_cleaning")
+ SINGLE_ROOM_CLEANING,
+ @SerializedName("clean_speed_control")
+ CLEAN_SPEED_CONTROL,
+ @SerializedName("mapping")
+ MAPPING,
+ @SerializedName("auto_empty_station")
+ AUTO_EMPTY_STATION,
+ @SerializedName("read_network_info")
+ READ_NETWORK_INFO,
+ @SerializedName("true_detect_3d")
+ TRUE_DETECT_3D,
+ @SerializedName("unit_care_lifespan")
+ UNIT_CARE_LIFESPAN,
+ // implicit capabilities added in code
+ EDGE_CLEANING,
+ SPOT_CLEANING,
+ EXTENDED_CLEAN_SPEED_CONTROL,
+ EXTENDED_CLEAN_LOG_RECORD,
+ DEFAULT_CLEAN_COUNT_SETTING
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/MoppingWaterAmount.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/MoppingWaterAmount.java
new file mode 100644
index 0000000000000..07cdf60cea8a0
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/MoppingWaterAmount.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public enum MoppingWaterAmount {
+ LOW,
+ MEDIUM,
+ HIGH,
+ VERY_HIGH;
+
+ public static MoppingWaterAmount fromApiValue(int value) {
+ return MoppingWaterAmount.values()[value - 1];
+ }
+
+ public int toApiValue() {
+ return ordinal() + 1;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/NetworkInfo.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/NetworkInfo.java
new file mode 100644
index 0000000000000..aa6ff69981a72
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/NetworkInfo.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class NetworkInfo {
+ public final String ipAddress;
+ public final String macAddress;
+ public final String wifiSsid;
+ public final int wifiRssi;
+
+ public NetworkInfo(String ip, String mac, String ssid, int rssi) {
+ this.ipAddress = ip;
+ this.macAddress = mac;
+ this.wifiSsid = ssid;
+ this.wifiRssi = rssi;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SpotAreaType.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SpotAreaType.java
new file mode 100644
index 0000000000000..c68bee50deda7
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SpotAreaType.java
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public enum SpotAreaType {
+ LIVING_ROOM(1),
+ DINING_ROOM(2),
+ BEDROOM(3),
+ OFFICE(4),
+ KITCHEN(5),
+ BATHROOM(6),
+ LAUNDRY_ROOM(7),
+ LOUNGE(8),
+ STORAGE_ROOM(9),
+ CHILDS_ROOM(10),
+ SUN_ROOM(11),
+ CORRIDOR(12),
+ BALCONY(13),
+ GYM(14);
+
+ private final int type;
+
+ private SpotAreaType(int type) {
+ this.type = type;
+ }
+
+ public SpotAreaType fromApiResponse(String response) throws NumberFormatException, IllegalArgumentException {
+ int id = Integer.parseInt(response);
+ for (SpotAreaType t : values()) {
+ if (t.type == id) {
+ return t;
+ }
+ }
+ throw new IllegalArgumentException("Unknown spot area type " + response);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SuctionPower.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SuctionPower.java
new file mode 100644
index 0000000000000..12992e1b866bb
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SuctionPower.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2010-2023 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.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Johannes Ptaszyk - Initial contribution
+ */
+@NonNullByDefault
+public enum SuctionPower {
+ @SerializedName("standard")
+ NORMAL,
+ @SerializedName("strong")
+ HIGH,
+ HIGHER,
+ SILENT;
+
+ public static SuctionPower fromJsonValue(int value) {
+ switch (value) {
+ case 1000:
+ return SILENT;
+ case 1:
+ return HIGH;
+ case 2:
+ return HIGHER;
+ default:
+ return NORMAL;
+ }
+ }
+
+ public int toJsonValue() {
+ switch (this) {
+ case HIGH:
+ return 1;
+ case HIGHER:
+ return 2;
+ case SILENT:
+ return 1000;
+ default: // NORMAL
+ return 0;
+ }
+ }
+
+ public String toXmlValue() {
+ if (this == HIGH) {
+ return "strong";
+ }
+ return "standard";
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/DataParsingException.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/DataParsingException.java
new file mode 100644
index 0000000000000..c59faf6d98d22
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/DataParsingException.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2023 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 org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class DataParsingException extends Exception {
+ private static final long serialVersionUID = -1486602104263772955L;
+
+ public DataParsingException(String message) {
+ super(message);
+ }
+
+ public DataParsingException(Exception cause) {
+ super(cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/MD5Util.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/MD5Util.java
new file mode 100644
index 0000000000000..0514c8b2222e5
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/MD5Util.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2023 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();
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/SchedulerTask.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/SchedulerTask.java
new file mode 100644
index 0000000000000..c0e8cd18d96de
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/SchedulerTask.java
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2010-2023 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.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.slf4j.Logger;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class SchedulerTask implements Runnable {
+ private final Logger logger;
+ private final String name;
+ private String prefixedName;
+ private final Runnable runnable;
+ private final ScheduledExecutorService scheduler;
+ private @Nullable Future> future;
+
+ public SchedulerTask(ScheduledExecutorService scheduler, Logger logger, String name, Runnable runnable) {
+ this.logger = logger;
+ this.name = name;
+ this.prefixedName = name;
+ this.runnable = runnable;
+ this.scheduler = scheduler;
+ }
+
+ public void setNamePrefix(String prefix) {
+ if (future != null) {
+ throw new IllegalStateException("Must not set prefix while scheduled");
+ }
+ if (prefix.isEmpty()) {
+ prefixedName = name;
+ } else {
+ prefixedName = prefix + ": " + name;
+ }
+ }
+
+ public void submit() {
+ schedule(0);
+ }
+
+ public synchronized void schedule(long delaySeconds) {
+ if (future != null) {
+ logger.trace("{}: Already scheduled to run", prefixedName);
+ return;
+ }
+ logger.trace("{}: Scheduling to run in {} seconds", prefixedName, delaySeconds);
+ if (delaySeconds == 0) {
+ future = scheduler.submit(this);
+ } else {
+ future = scheduler.schedule(this, delaySeconds, TimeUnit.SECONDS);
+ }
+ }
+
+ public synchronized void scheduleRecurring(long intervalSeconds) {
+ if (future != null) {
+ logger.trace("{}: Already scheduled to run", prefixedName);
+ return;
+ }
+ logger.trace("{}: Scheduling to run in {} second intervals", prefixedName, intervalSeconds);
+ future = scheduler.scheduleWithFixedDelay(runnable, 0, intervalSeconds, TimeUnit.SECONDS);
+ }
+
+ public synchronized void cancel() {
+ Future> future = this.future;
+ this.future = null;
+ if (future != null) {
+ future.cancel(true);
+ logger.trace("{}: Cancelled", prefixedName);
+ }
+ }
+
+ @Override
+ public void run() {
+ synchronized (this) {
+ future = null;
+ }
+ logger.trace("{}: Running one-shot", prefixedName);
+ runnable.run();
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/XPathUtils.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/XPathUtils.java
new file mode 100644
index 0000000000000..5305221272549
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/XPathUtils.java
@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2010-2023 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.io.StringReader;
+import java.util.Optional;
+
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathExpressionException;
+import javax.xml.xpath.XPathFactory;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class XPathUtils {
+ private static @Nullable XPathFactory factory;
+
+ public static Node getFirstXPathMatch(String xml, String xpathExpression) throws DataParsingException {
+ NodeList nodes = getXPathMatches(xml, xpathExpression);
+ if (nodes.getLength() == 0) {
+ throw new DataParsingException("No nodes matching expression " + xpathExpression + " in XML " + xml);
+ }
+ return nodes.item(0);
+ }
+
+ public static Optional getFirstXPathMatchOpt(String xml, String xpathExpression) throws DataParsingException {
+ NodeList nodes = getXPathMatches(xml, xpathExpression);
+ return nodes.getLength() == 0 ? Optional.empty() : Optional.of(nodes.item(0));
+ }
+
+ public static NodeList getXPathMatches(String xml, String xpathExpression) throws DataParsingException {
+ try {
+ InputSource source = new InputSource(new StringReader(xml));
+ return (NodeList) newXPath().evaluate(xpathExpression, source, XPathConstants.NODESET);
+ } catch (XPathExpressionException e) {
+ throw new DataParsingException(e);
+ }
+ }
+
+ @SuppressWarnings("null") // null annotations don't recognize FACTORY can not be null in return statement
+ private static XPath newXPath() {
+ synchronized (XPathUtils.class) {
+ if (factory == null) {
+ factory = XPathFactory.newInstance();
+ }
+ return factory.newXPath();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsApiConfiguration.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsApiConfiguration.java
new file mode 100644
index 0000000000000..0e65c2decf69e
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsApiConfiguration.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2023 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.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link EcovacsApiConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsApiConfiguration {
+ public String email = "";
+ public String password = "";
+ public String continent = "ww";
+ public String installId = "";
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsVacuumConfiguration.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsVacuumConfiguration.java
new file mode 100644
index 0000000000000..11fbde9455e05
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsVacuumConfiguration.java
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2023 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.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link EcovacsVacuumConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsVacuumConfiguration {
+ public String serialNumber = "";
+ public int refresh = 5; // in minutes
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/discovery/EcovacsDeviceDiscoveryService.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/discovery/EcovacsDeviceDiscoveryService.java
new file mode 100644
index 0000000000000..33a3ce49c0631
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/discovery/EcovacsDeviceDiscoveryService.java
@@ -0,0 +1,151 @@
+/**
+ * Copyright (c) 2010-2023 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.discovery;
+
+import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+import org.openhab.binding.ecovacs.internal.api.util.SchedulerTask;
+import org.openhab.binding.ecovacs.internal.handler.EcovacsApiHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.osgi.service.component.annotations.Component;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EcovacsDeviceDiscoveryService} is used for discovering devices registered in the cloud account.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = DiscoveryService.class, configurationPid = "discovery.ecovacs")
+public class EcovacsDeviceDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
+ private final Logger logger = LoggerFactory.getLogger(EcovacsDeviceDiscoveryService.class);
+
+ private static final int DISCOVER_TIMEOUT_SECONDS = 10;
+
+ private @NonNullByDefault({}) EcovacsApiHandler apiHandler;
+ private Optional api = Optional.empty();
+ private final SchedulerTask onDemandScanTask = new SchedulerTask(scheduler, logger, "OnDemandScan",
+ this::scanForDevices);
+ private final SchedulerTask backgroundScanTask = new SchedulerTask(scheduler, logger, "BackgroundScan",
+ this::scanForDevices);
+
+ public EcovacsDeviceDiscoveryService() {
+ super(Collections.singleton(THING_TYPE_VACUUM), DISCOVER_TIMEOUT_SECONDS, true);
+ }
+
+ @Override
+ public void setThingHandler(@Nullable ThingHandler handler) {
+ if (handler instanceof EcovacsApiHandler) {
+ this.apiHandler = (EcovacsApiHandler) handler;
+ this.apiHandler.setDiscoveryService(this);
+ }
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return apiHandler;
+ }
+
+ @Override
+ public void activate() {
+ super.activate(null);
+ }
+
+ @Override
+ public void deactivate() {
+ super.deactivate();
+ }
+
+ @Override
+ protected synchronized void startBackgroundDiscovery() {
+ stopBackgroundDiscovery();
+ backgroundScanTask.scheduleRecurring(60);
+ }
+
+ @Override
+ protected synchronized void stopBackgroundDiscovery() {
+ backgroundScanTask.cancel();
+ }
+
+ public synchronized void startScanningWithApi(EcovacsApi api) {
+ this.api = Optional.of(api);
+ onDemandScanTask.cancel();
+ startScan();
+ }
+
+ @Override
+ public synchronized void startScan() {
+ logger.debug("Starting Ecovacs discovery scan");
+ onDemandScanTask.submit();
+ }
+
+ @Override
+ public synchronized void stopScan() {
+ logger.debug("Stopping Ecovacs discovery scan");
+ onDemandScanTask.cancel();
+ super.stopScan();
+ }
+
+ private void scanForDevices() {
+ this.api.ifPresent(api -> {
+ long timestampOfLastScan = getTimestampOfLastScan();
+ try {
+ List devices = api.getDevices();
+ logger.debug("Ecovacs discovery found {} devices", devices.size());
+
+ for (EcovacsDevice device : devices) {
+ deviceDiscovered(device);
+ }
+ for (Thing thing : apiHandler.getThing().getThings()) {
+ String serial = thing.getUID().getId();
+ if (!devices.stream().anyMatch(d -> serial.equals(d.getSerialNumber()))) {
+ thingRemoved(thing.getUID());
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (EcovacsApiException e) {
+ logger.debug("Could not retrieve devices from Ecovacs API", e);
+ } finally {
+ removeOlderResults(timestampOfLastScan);
+ }
+ });
+ }
+
+ private void deviceDiscovered(EcovacsDevice device) {
+ ThingUID thingUID = new ThingUID(THING_TYPE_VACUUM, apiHandler.getThing().getUID(), device.getSerialNumber());
+ DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID)
+ .withBridge(apiHandler.getThing().getUID()).withLabel(device.getModelName())
+ .withProperty(Thing.PROPERTY_SERIAL_NUMBER, device.getSerialNumber())
+ .withProperty(Thing.PROPERTY_MODEL_ID, device.getModelName())
+ .withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER).build();
+ thingDiscovered(discoveryResult);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsApiHandler.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsApiHandler.java
new file mode 100644
index 0000000000000..70c26224ae72e
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsApiHandler.java
@@ -0,0 +1,153 @@
+/**
+ * Copyright (c) 2010-2023 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.handler;
+
+import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
+import org.openhab.binding.ecovacs.internal.api.util.SchedulerTask;
+import org.openhab.binding.ecovacs.internal.config.EcovacsApiConfiguration;
+import org.openhab.binding.ecovacs.internal.discovery.EcovacsDeviceDiscoveryService;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.i18n.ConfigurationException;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EcovacsApiHandler} is responsible for connecting to the Ecovacs cloud API account.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsApiHandler extends BaseBridgeHandler {
+ private final Logger logger = LoggerFactory.getLogger(EcovacsApiHandler.class);
+ private static final long RETRY_INTERVAL_SECONDS = 120;
+
+ private Optional discoveryService = Optional.empty();
+ private SchedulerTask loginTask;
+ private final HttpClient httpClient;
+ private final LocaleProvider localeProvider;
+
+ public EcovacsApiHandler(Bridge bridge, HttpClient httpClient, LocaleProvider localeProvider) {
+ super(bridge);
+ this.httpClient = httpClient;
+ this.localeProvider = localeProvider;
+ this.loginTask = new SchedulerTask(scheduler, logger, "API Login", this::loginToApi);
+ }
+
+ public void setDiscoveryService(EcovacsDeviceDiscoveryService discoveryService) {
+ this.discoveryService = Optional.of(discoveryService);
+ }
+
+ public EcovacsApi createApiForDevice(String serial) throws ConfigurationException {
+ String country = localeProvider.getLocale().getCountry();
+ if (country.isEmpty()) {
+ throw new ConfigurationException("@text/offline.config-error-no-country");
+ }
+ return createApi("-" + serial, country);
+ }
+
+ @Override
+ public void initialize() {
+ logger.debug("Initializing Ecovacs account '{}'", getThing().getUID().getId());
+ // The API expects us to provide a unique device ID during authentication, so generate one once
+ // and keep it in configuration afterwards
+ if (!getConfig().keySet().contains("installId")) {
+ Configuration newConfig = editConfiguration();
+ newConfig.put("installId", UUID.randomUUID().toString());
+ updateConfiguration(newConfig);
+ }
+ updateStatus(ThingStatus.UNKNOWN);
+ loginTask.submit();
+ }
+
+ @Override
+ public void dispose() {
+ super.dispose();
+ discoveryService.ifPresent(ds -> ds.stopScan());
+ }
+
+ @Override
+ public Collection> getServices() {
+ return Collections.singleton(EcovacsDeviceDiscoveryService.class);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ if (RefreshType.REFRESH == command) {
+ logger.debug("Refreshing Ecovacs API account '{}'", getThing().getUID().getId());
+ scheduleLogin(0);
+ }
+ }
+
+ public void onLoginExpired() {
+ logger.debug("Ecovacs API login for account '{}' expired, logging in again", getThing().getUID().getId());
+ scheduleLogin(0);
+ }
+
+ private void scheduleLogin(long delaySeconds) {
+ loginTask.cancel();
+ loginTask.schedule(delaySeconds);
+ }
+
+ private EcovacsApi createApi(String deviceIdSuffix, String country) {
+ EcovacsApiConfiguration config = getConfigAs(EcovacsApiConfiguration.class);
+ 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);
+
+ return EcovacsApi.create(httpClient, apiConfig);
+ }
+
+ private void loginToApi() {
+ try {
+ String country = localeProvider.getLocale().getCountry();
+ if (country.isEmpty()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ "@text/offline.config-error-no-country");
+ return;
+ }
+ EcovacsApi api = createApi("", country);
+ api.loginAndGetAccessToken();
+ updateStatus(ThingStatus.ONLINE);
+ discoveryService.ifPresent(ds -> ds.startScanningWithApi(api));
+
+ logger.debug("Ecovacs API initialized");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ updateStatus(ThingStatus.OFFLINE);
+ } catch (EcovacsApiException e) {
+ logger.debug("Ecovacs API login failed", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ scheduleLogin(RETRY_INTERVAL_SECONDS);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsVacuumHandler.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsVacuumHandler.java
new file mode 100644
index 0000000000000..febdb7edc17ef
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsVacuumHandler.java
@@ -0,0 +1,822 @@
+/**
+ * Copyright (c) 2010-2023 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.handler;
+
+import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
+
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.EcovacsDynamicStateDescriptionProvider;
+import org.openhab.binding.ecovacs.internal.action.EcovacsVacuumActions;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
+import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
+import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
+import org.openhab.binding.ecovacs.internal.api.commands.AbstractNoResponseCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.CustomAreaCleaningCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.EmptyDustbinCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetBatteryInfoCommand;
+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.GetDefaultCleanPassesCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetDustbinAutoEmptyCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetErrorCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetMoppingWaterAmountCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetNetworkInfoCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetSuctionPowerCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetTotalStatsCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetTotalStatsCommand.TotalStats;
+import org.openhab.binding.ecovacs.internal.api.commands.GetTrueDetectCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetVolumeCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GetWaterSystemPresentCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.GoChargingCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.PauseCleaningCommand;
+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.SetContinuousCleaningCommand;
+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;
+import org.openhab.binding.ecovacs.internal.api.commands.SetSuctionPowerCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.SetTrueDetectCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.SetVolumeCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.SpotAreaCleaningCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.StartAutoCleaningCommand;
+import org.openhab.binding.ecovacs.internal.api.commands.StopCleaningCommand;
+import org.openhab.binding.ecovacs.internal.api.model.ChargeMode;
+import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
+import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
+import org.openhab.binding.ecovacs.internal.api.model.Component;
+import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
+import org.openhab.binding.ecovacs.internal.api.model.MoppingWaterAmount;
+import org.openhab.binding.ecovacs.internal.api.model.NetworkInfo;
+import org.openhab.binding.ecovacs.internal.api.model.SuctionPower;
+import org.openhab.binding.ecovacs.internal.api.util.SchedulerTask;
+import org.openhab.binding.ecovacs.internal.config.EcovacsVacuumConfiguration;
+import org.openhab.binding.ecovacs.internal.util.StateOptionEntry;
+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;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.StateOption;
+import org.openhab.core.types.UnDefType;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EcovacsVacuumHandler} is responsible for handling data and commands from/to vacuum cleaners.
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDevice.EventListener {
+
+ private final Logger logger = LoggerFactory.getLogger(EcovacsVacuumHandler.class);
+
+ private final TranslationProvider i18Provider;
+ private final LocaleProvider localeProvider;
+ private final EcovacsDynamicStateDescriptionProvider stateDescriptionProvider;
+ private final Bundle bundle;
+
+ private final SchedulerTask initTask;
+ private final SchedulerTask reconnectTask;
+ private final SchedulerTask pollTask;
+ private @Nullable EcovacsDevice device;
+
+ private @Nullable Boolean lastWasCharging;
+ private @Nullable CleanMode lastCleanMode;
+ private @Nullable CleanMode lastActiveCleanMode;
+ private Optional lastDownloadedCleanMapUrl = Optional.empty();
+ private long lastSuccessfulPollTimestamp;
+ private int lastDefaultCleaningPasses = 1;
+ private String serialNumber = "";
+
+ public EcovacsVacuumHandler(Thing thing, TranslationProvider i18Provider, LocaleProvider localeProvider,
+ EcovacsDynamicStateDescriptionProvider stateDescriptionProvider) {
+ super(thing);
+ this.i18Provider = i18Provider;
+ this.localeProvider = localeProvider;
+ this.stateDescriptionProvider = stateDescriptionProvider;
+ bundle = FrameworkUtil.getBundle(getClass());
+
+ initTask = new SchedulerTask(scheduler, logger, "Init", this::initDevice);
+ reconnectTask = new SchedulerTask(scheduler, logger, "Connection", this::connectToDevice);
+ pollTask = new SchedulerTask(scheduler, logger, "Poll", this::pollData);
+ }
+
+ @Override
+ public Collection> getServices() {
+ return Collections.singleton(EcovacsVacuumActions.class);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ final EcovacsDevice device = this.device;
+ if (device == null) {
+ logger.debug("{}: Ignoring command {}, no active connection", serialNumber, command);
+ return;
+ }
+ String channel = channelUID.getId();
+
+ try {
+ if (channel.equals(CHANNEL_ID_COMMAND) && command instanceof StringType) {
+ AbstractNoResponseCommand cmd = determineDeviceCommand(device, command.toString());
+ if (cmd != null) {
+ device.sendCommand(cmd);
+ return;
+ }
+ } else if (channel.equals(CHANNEL_ID_VOICE_VOLUME) && command instanceof DecimalType) {
+ int volumePercent = ((DecimalType) command).intValue();
+ device.sendCommand(new SetVolumeCommand((volumePercent + 5) / 10));
+ return;
+ } else if (channel.equals(CHANNEL_ID_SUCTION_POWER) && command instanceof StringType) {
+ Optional power = SUCTION_POWER_MAPPING.findMappedEnumValue(command.toString());
+ if (power.isPresent()) {
+ device.sendCommand(new SetSuctionPowerCommand(power.get()));
+ return;
+ }
+ } else if (channel.equals(CHANNEL_ID_WATER_AMOUNT) && command instanceof StringType) {
+ Optional amount = WATER_AMOUNT_MAPPING.findMappedEnumValue(command.toString());
+ if (amount.isPresent()) {
+ device.sendCommand(new SetMoppingWaterAmountCommand(amount.get()));
+ return;
+ }
+ } else if (channel.equals(CHANNEL_ID_AUTO_EMPTY)) {
+ if (command instanceof OnOffType) {
+ device.sendCommand(new SetDustbinAutoEmptyCommand(command == OnOffType.ON));
+ return;
+ } else if (command instanceof StringType && command.toString().equals("trigger")) {
+ device.sendCommand(new EmptyDustbinCommand());
+ return;
+ }
+ } else if (channel.equals(CHANNEL_ID_TRUE_DETECT_3D) && command instanceof OnOffType) {
+ device.sendCommand(new SetTrueDetectCommand(command == OnOffType.ON));
+ return;
+ } else if (channel.equals(CHANNEL_ID_CONTINUOUS_CLEANING) && command instanceof OnOffType) {
+ device.sendCommand(new SetContinuousCleaningCommand(command == OnOffType.ON));
+ return;
+ } else if (channel.equals(CHANNEL_ID_CLEANING_PASSES) && command instanceof DecimalType) {
+ int passes = ((DecimalType) command).intValue();
+ device.sendCommand(new SetDefaultCleanPassesCommand(passes));
+ lastDefaultCleaningPasses = passes; // if we get here, the command was executed successfully
+ return;
+ }
+ logger.debug("{}: Ignoring unsupported device command {} for channel {}", serialNumber, command, channel);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (EcovacsApiException e) {
+ logger.debug("{}: Handling device command {} failed", serialNumber, command, e);
+ }
+ }
+
+ @Override
+ public void initialize() {
+ serialNumber = getConfigAs(EcovacsVacuumConfiguration.class).serialNumber;
+ if (serialNumber.isEmpty()) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/offline.config-error-no-serial");
+ } else {
+ logger.debug("{}: Initializing handler", serialNumber);
+ updateStatus(ThingStatus.UNKNOWN);
+ initTask.setNamePrefix(serialNumber);
+ reconnectTask.setNamePrefix(serialNumber);
+ pollTask.setNamePrefix(serialNumber);
+ initTask.submit();
+ }
+ }
+
+ @Override
+ public void dispose() {
+ logger.debug("{}: Disposing handler", serialNumber);
+ teardown(false);
+ }
+
+ @Override
+ public void channelLinked(ChannelUID channelUID) {
+ EcovacsDevice device = this.device;
+ if (device == null) {
+ return;
+ }
+
+ try {
+ switch (channelUID.getId()) {
+ case CHANNEL_ID_BATTERY_LEVEL:
+ fetchInitialBatteryStatus(device);
+ break;
+ case CHANNEL_ID_STATE:
+ case CHANNEL_ID_COMMAND:
+ case CHANNEL_ID_CLEANING_MODE:
+ fetchInitialStateAndCommandValues(device);
+ break;
+ case CHANNEL_ID_WATER_PLATE_PRESENT:
+ fetchInitialWaterSystemPresentState(device);
+ break;
+ case CHANNEL_ID_ERROR_CODE:
+ case CHANNEL_ID_ERROR_DESCRIPTION:
+ fetchInitialErrorCode(device);
+ default:
+ scheduleNextPoll(5); // add some delay in case multiple channels are linked at once
+ break;
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (EcovacsApiException e) {
+ logger.debug("{}: Fetching initial data for channel {} failed", serialNumber, channelUID.getId(), e);
+ }
+ }
+
+ @Override
+ public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+ logger.debug("{}: Bridge status changed to {}", serialNumber, bridgeStatusInfo);
+ if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
+ initTask.submit();
+ } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
+ teardown(false);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ }
+ }
+
+ @Override
+ public void onBatteryLevelUpdated(EcovacsDevice device, int newLevelPercent) {
+ // Some devices report weird values (> 100%), so better clamp to supported range
+ int actualPercent = Math.max(0, Math.min(newLevelPercent, 100));
+ updateState(CHANNEL_ID_BATTERY_LEVEL, new DecimalType(actualPercent));
+ }
+
+ @Override
+ public void onChargingStateUpdated(EcovacsDevice device, boolean charging) {
+ lastWasCharging = charging;
+ updateStateAndCommandChannels();
+ }
+
+ @Override
+ public void onCleaningModeUpdated(EcovacsDevice device, CleanMode newMode, Optional areaDefinition) {
+ lastCleanMode = newMode;
+ if (newMode.isActive()) {
+ lastActiveCleanMode = newMode;
+ } else if (newMode.isIdle()) {
+ lastActiveCleanMode = null;
+ }
+ updateStateAndCommandChannels();
+ Optional areaDefState = areaDefinition.map(def -> {
+ if (newMode == CleanMode.SPOT_AREA) {
+ // Map indices back to letters as shown in the app
+ def = Arrays.stream(def.split(",")).map(item -> {
+ try {
+ int index = Integer.parseInt(item);
+ return String.valueOf((char) ('A' + index));
+ } catch (NumberFormatException e) {
+ return item;
+ }
+ }).collect(Collectors.joining(";"));
+ } else if (newMode == CleanMode.CUSTOM_AREA) {
+ // Map the separator from comma to semicolon to allow using the output as command input
+ def = def.replace(',', ';');
+ }
+ return new StringType(def);
+ });
+ updateState(CHANNEL_ID_CLEANING_SPOT_DEFINITION, areaDefState.orElse(UnDefType.UNDEF));
+ if (newMode == CleanMode.RETURNING) {
+ scheduleNextPoll(30);
+ } else if (newMode.isIdle()) {
+ updateState(CHANNEL_ID_CLEANED_AREA, UnDefType.UNDEF);
+ updateState(CHANNEL_ID_CLEANING_TIME, UnDefType.UNDEF);
+ }
+ }
+
+ @Override
+ public void onCleaningStatsUpdated(EcovacsDevice device, int cleanedArea, int cleaningTimeSeconds) {
+ updateState(CHANNEL_ID_CLEANED_AREA, new QuantityType<>(cleanedArea, SIUnits.SQUARE_METRE));
+ updateState(CHANNEL_ID_CLEANING_TIME, new QuantityType<>(cleaningTimeSeconds, Units.SECOND));
+ }
+
+ @Override
+ public void onWaterSystemPresentUpdated(EcovacsDevice device, boolean present) {
+ updateState(CHANNEL_ID_WATER_PLATE_PRESENT, OnOffType.from(present));
+ }
+
+ @Override
+ public void onErrorReported(EcovacsDevice device, int errorCode) {
+ updateState(CHANNEL_ID_ERROR_CODE, new DecimalType(errorCode));
+ final Locale locale = localeProvider.getLocale();
+ String errorDesc = i18Provider.getText(bundle, "ecovacs.vacuum.error-code." + errorCode, null, locale);
+ if (errorDesc == null) {
+ errorDesc = i18Provider.getText(bundle, "ecovacs.vacuum.error-code.unknown", "", locale, errorCode);
+ }
+ updateState(CHANNEL_ID_ERROR_DESCRIPTION, new StringType(errorDesc));
+ }
+
+ @Override
+ public void onEventStreamFailure(final EcovacsDevice device, Throwable error) {
+ logger.debug("{}: Device connection failed, reconnecting", serialNumber, error);
+ teardownAndScheduleReconnection();
+ }
+
+ @Override
+ public void onFirmwareVersionChanged(EcovacsDevice device, String fwVersion) {
+ updateProperty(Thing.PROPERTY_FIRMWARE_VERSION, fwVersion);
+ }
+
+ public void playSound(PlaySoundCommand command) {
+ doWithDevice(device -> {
+ if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
+ device.sendCommand(command);
+ } else {
+ logger.info("{}: Device does not support voice reporting, ignoring sound action", serialNumber);
+ }
+ });
+ }
+
+ private void fetchInitialBatteryStatus(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
+ Integer batteryPercent = device.sendCommand(new GetBatteryInfoCommand());
+ onBatteryLevelUpdated(device, batteryPercent);
+ }
+
+ private void fetchInitialStateAndCommandValues(EcovacsDevice device)
+ throws EcovacsApiException, InterruptedException {
+ lastWasCharging = device.sendCommand(new GetChargeStateCommand()) == ChargeMode.CHARGING;
+ CleanMode mode = device.sendCommand(new GetCleanStateCommand());
+ if (mode.isActive()) {
+ lastActiveCleanMode = mode;
+ }
+ lastCleanMode = mode;
+ updateStateAndCommandChannels();
+ }
+
+ private void fetchInitialWaterSystemPresentState(EcovacsDevice device)
+ throws EcovacsApiException, InterruptedException {
+ if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
+ return;
+ }
+ boolean present = device.sendCommand(new GetWaterSystemPresentCommand());
+ onWaterSystemPresentUpdated(device, present);
+ }
+
+ private void fetchInitialErrorCode(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
+ Optional errorOpt = device.sendCommand(new GetErrorCommand());
+ if (errorOpt.isPresent()) {
+ onErrorReported(device, errorOpt.get());
+ }
+ }
+
+ private void removeUnsupportedChannels(EcovacsDevice device) {
+ ThingBuilder builder = editThing();
+ boolean hasChanges = false;
+
+ if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_PLATE_PRESENT);
+ }
+ if (!device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_SUCTION_POWER);
+ }
+ if (!device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_MAIN_BRUSH_LIFETIME);
+ }
+ if (!device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_VOICE_VOLUME);
+ }
+ if (!device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MODE);
+ }
+ if (!device.hasCapability(DeviceCapability.MAPPING)
+ || !device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MAP);
+ }
+ if (!device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WIFI_RSSI);
+ }
+ if (!device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_AUTO_EMPTY);
+ }
+ if (!device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_TRUE_DETECT_3D);
+ }
+ if (!device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
+ hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_CLEANING_PASSES);
+ }
+
+ if (hasChanges) {
+ updateThing(builder.build());
+ }
+ }
+
+ private boolean removeUnsupportedChannel(ThingBuilder builder, String channelId) {
+ ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
+ if (getThing().getChannel(channelUID) == null) {
+ return false;
+ }
+ logger.debug("{}: Removing unsupported channel {}", serialNumber, channelId);
+ builder.withoutChannel(channelUID);
+ return true;
+ }
+
+ private void updateStateOptions(EcovacsDevice device) {
+ List modeChannelOptions = createChannelOptions(device, CleanMode.values(), CLEAN_MODE_MAPPING,
+ m -> m.enumValue.isActive());
+ ThingUID thingUID = getThing().getUID();
+
+ stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_CLEANING_MODE),
+ modeChannelOptions);
+ stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_LAST_CLEAN_MODE),
+ modeChannelOptions);
+ stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_SUCTION_POWER),
+ createChannelOptions(device, SuctionPower.values(), SUCTION_POWER_MAPPING, null));
+ stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_WATER_AMOUNT),
+ createChannelOptions(device, MoppingWaterAmount.values(), WATER_AMOUNT_MAPPING, null));
+ }
+
+ private > List createChannelOptions(EcovacsDevice device, T[] values,
+ StateOptionMapping mapping, @Nullable Predicate> filter) {
+ return Arrays.stream(values).map(v -> Optional.ofNullable(mapping.get(v)))
+ // ensure we have a mapping (should always be the case)
+ .filter(Optional::isPresent).map(opt -> opt.get())
+ // apply supplied filter
+ .filter(mv -> filter == null || filter.test(mv))
+ // apply capability filter
+ .filter(mv -> mv.capability.isEmpty() || device.hasCapability(mv.capability.get()))
+ // map to actual option
+ .map(mv -> new StateOption(mv.value, mv.value)).collect(Collectors.toList());
+ }
+
+ private synchronized void scheduleNextPoll(long initialDelaySeconds) {
+ final EcovacsVacuumConfiguration config = getConfigAs(EcovacsVacuumConfiguration.class);
+ final long delayUntilNextPoll;
+ if (initialDelaySeconds < 0) {
+ long intervalSeconds = config.refresh * 60;
+ long secondsSinceLastPoll = (System.currentTimeMillis() - lastSuccessfulPollTimestamp) / 1000;
+ long deltaRemaining = intervalSeconds - secondsSinceLastPoll;
+ delayUntilNextPoll = Math.max(0, deltaRemaining);
+ } else {
+ delayUntilNextPoll = initialDelaySeconds;
+ }
+ logger.debug("{}: Scheduling next poll in {}s, refresh interval {}min", serialNumber, delayUntilNextPoll,
+ config.refresh);
+ pollTask.cancel();
+ pollTask.schedule(delayUntilNextPoll);
+ }
+
+ private void initDevice() {
+ final EcovacsApiHandler handler = getApiHandler();
+ if (handler == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
+ return;
+ }
+
+ try {
+ final EcovacsApi api = handler.createApiForDevice(serialNumber);
+ api.loginAndGetAccessToken();
+ Optional deviceOpt = api.getDevices().stream()
+ .filter(d -> serialNumber.equals(d.getSerialNumber())).findFirst();
+ if (deviceOpt.isPresent()) {
+ EcovacsDevice device = deviceOpt.get();
+ this.device = device;
+ updateProperty(Thing.PROPERTY_MODEL_ID, device.getModelName());
+ updateProperty(Thing.PROPERTY_SERIAL_NUMBER, device.getSerialNumber());
+ updateStateOptions(device);
+ removeUnsupportedChannels(device);
+ connectToDevice();
+ } else {
+ logger.info("{}: Device not found in device list, setting offline", serialNumber);
+ updateStatus(ThingStatus.OFFLINE);
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (ConfigurationException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
+ } catch (EcovacsApiException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ }
+ }
+
+ private void teardownAndScheduleReconnection() {
+ teardown(true);
+ }
+
+ private synchronized void teardown(boolean scheduleReconnection) {
+ EcovacsDevice device = this.device;
+ if (device != null) {
+ device.disconnect(scheduler);
+ }
+
+ pollTask.cancel();
+
+ reconnectTask.cancel();
+ initTask.cancel();
+
+ if (scheduleReconnection) {
+ SchedulerTask connectTask = device != null ? reconnectTask : initTask;
+ connectTask.schedule(5);
+ }
+ }
+
+ private void connectToDevice() {
+ doWithDevice(device -> {
+ device.connect(this, scheduler);
+ fetchInitialBatteryStatus(device);
+ fetchInitialStateAndCommandValues(device);
+ fetchInitialWaterSystemPresentState(device); // nop if unsupported
+ fetchInitialErrorCode(device);
+ scheduleNextPoll(-1);
+ logger.debug("{}: Device connected", serialNumber);
+ updateStatus(ThingStatus.ONLINE);
+ });
+ }
+
+ private void pollData() {
+ logger.debug("{}: Polling data", serialNumber);
+ doWithDevice(device -> {
+ TotalStats totalStats = device.sendCommand(new GetTotalStatsCommand());
+ updateState(CHANNEL_ID_TOTAL_CLEANED_AREA, new QuantityType<>(totalStats.totalArea, SIUnits.SQUARE_METRE));
+ updateState(CHANNEL_ID_TOTAL_CLEANING_TIME, new QuantityType<>(totalStats.totalRuntime, Units.SECOND));
+ updateState(CHANNEL_ID_TOTAL_CLEAN_RUNS, new DecimalType(totalStats.cleanRuns));
+
+ boolean continuousCleaningEnabled = device.sendCommand(new GetContinuousCleaningCommand());
+ updateState(CHANNEL_ID_CONTINUOUS_CLEANING, continuousCleaningEnabled ? OnOffType.ON : OnOffType.OFF);
+
+ List cleanLogRecords = device.getCleanLogs();
+ if (!cleanLogRecords.isEmpty()) {
+ CleanLogRecord record = cleanLogRecords.get(0);
+
+ updateState(CHANNEL_ID_LAST_CLEAN_START,
+ new DateTimeType(record.timestamp.toInstant().atZone(ZoneId.systemDefault())));
+ updateState(CHANNEL_ID_LAST_CLEAN_DURATION, new QuantityType<>(record.cleaningDuration, Units.SECOND));
+ updateState(CHANNEL_ID_LAST_CLEAN_AREA, new QuantityType<>(record.cleanedArea, SIUnits.SQUARE_METRE));
+ if (device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
+ StateOptionEntry mode = CLEAN_MODE_MAPPING.get(record.mode);
+ updateState(CHANNEL_ID_LAST_CLEAN_MODE, stringToState(mode != null ? mode.value : null));
+
+ 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));
+ }
+ }
+ }
+
+ if (device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
+ SuctionPower power = device.sendCommand(new GetSuctionPowerCommand());
+ updateState(CHANNEL_ID_SUCTION_POWER, new StringType(SUCTION_POWER_MAPPING.getMappedValue(power)));
+ }
+
+ 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.READ_NETWORK_INFO)) {
+ NetworkInfo netInfo = device.sendCommand(new GetNetworkInfoCommand());
+ if (netInfo.wifiRssi != 0) {
+ updateState(CHANNEL_ID_WIFI_RSSI, new QuantityType<>(netInfo.wifiRssi, Units.DECIBEL_MILLIWATTS));
+ }
+ }
+
+ if (device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
+ boolean autoEmptyEnabled = device.sendCommand(new GetDustbinAutoEmptyCommand());
+ updateState(CHANNEL_ID_AUTO_EMPTY, autoEmptyEnabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
+ boolean trueDetectEnabled = device.sendCommand(new GetTrueDetectCommand());
+ updateState(CHANNEL_ID_TRUE_DETECT_3D, trueDetectEnabled ? OnOffType.ON : OnOffType.OFF);
+ }
+ if (device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
+ lastDefaultCleaningPasses = device.sendCommand(new GetDefaultCleanPassesCommand());
+ updateState(CHANNEL_ID_CLEANING_PASSES, new DecimalType(lastDefaultCleaningPasses));
+ }
+
+ int sideBrushPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.SIDE_BRUSH));
+ updateState(CHANNEL_ID_SIDE_BRUSH_LIFETIME, new QuantityType<>(sideBrushPercent, Units.PERCENT));
+ int filterPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.DUST_CASE_HEAP));
+ updateState(CHANNEL_ID_DUST_FILTER_LIFETIME, new QuantityType<>(filterPercent, Units.PERCENT));
+
+ if (device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
+ int mainBrushPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.BRUSH));
+ updateState(CHANNEL_ID_MAIN_BRUSH_LIFETIME, new QuantityType<>(mainBrushPercent, Units.PERCENT));
+ }
+ if (device.hasCapability(DeviceCapability.UNIT_CARE_LIFESPAN)) {
+ int unitCarePercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.UNIT_CARE));
+ updateState(CHANNEL_ID_OTHER_COMPONENT_LIFETIME, new QuantityType<>(unitCarePercent, Units.PERCENT));
+ }
+ if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
+ int level = device.sendCommand(new GetVolumeCommand());
+ updateState(CHANNEL_ID_VOICE_VOLUME, new PercentType(level * 10));
+ }
+
+ lastSuccessfulPollTimestamp = System.currentTimeMillis();
+ scheduleNextPoll(-1);
+ });
+ logger.debug("{}: Data polling completed", serialNumber);
+ }
+
+ private void updateStateAndCommandChannels() {
+ Boolean charging = this.lastWasCharging;
+ CleanMode cleanMode = this.lastCleanMode;
+ if (charging == null || cleanMode == null) {
+ return;
+ }
+ String commandState = determineCommandChannelValue(charging, cleanMode);
+ String currentMode = determineCleaningModeChannelValue(cleanMode.isActive() ? cleanMode : lastActiveCleanMode);
+ updateState(CHANNEL_ID_STATE, StringType.valueOf(determineStateChannelValue(charging, cleanMode)));
+ updateState(CHANNEL_ID_CLEANING_MODE, stringToState(currentMode));
+ updateState(CHANNEL_ID_COMMAND, stringToState(commandState));
+ }
+
+ private String determineStateChannelValue(boolean charging, CleanMode cleanMode) {
+ if (charging) {
+ // Some devices already report charging state while returning to charging station, make sure to not report
+ // charging in that case. The same applies for models with pad washing/drying station, as those states imply
+ // the device being charging.
+ if (cleanMode != CleanMode.RETURNING && cleanMode != CleanMode.WASHING && cleanMode != CleanMode.DRYING) {
+ return "charging";
+ }
+ }
+ if (cleanMode.isActive()) {
+ return "cleaning";
+ }
+ StateOptionEntry result = CLEAN_MODE_MAPPING.get(cleanMode);
+ return result != null ? result.value : "idle";
+ }
+
+ private @Nullable String determineCleaningModeChannelValue(@Nullable CleanMode activeCleanMode) {
+ StateOptionEntry result = activeCleanMode != null ? CLEAN_MODE_MAPPING.get(activeCleanMode) : null;
+ return result != null ? result.value : null;
+ }
+
+ private @Nullable String determineCommandChannelValue(boolean charging, CleanMode cleanMode) {
+ if (charging) {
+ return CMD_CHARGE;
+ }
+ switch (cleanMode) {
+ case AUTO:
+ return CMD_AUTO_CLEAN;
+ case SPOT_AREA:
+ return CMD_SPOT_AREA;
+ case PAUSE:
+ return CMD_PAUSE;
+ case STOP:
+ return CMD_STOP;
+ case RETURNING:
+ return CMD_CHARGE;
+ default:
+ break;
+ }
+ return null;
+ }
+
+ private State stringToState(@Nullable String value) {
+ Optional stateOpt = Optional.ofNullable(value).map(v -> StringType.valueOf(v));
+ return stateOpt.orElse(UnDefType.UNDEF);
+ }
+
+ private @Nullable AbstractNoResponseCommand determineDeviceCommand(EcovacsDevice device, String command) {
+ CleanMode mode = lastActiveCleanMode;
+
+ switch (command) {
+ case CMD_AUTO_CLEAN:
+ return new StartAutoCleaningCommand();
+ case CMD_PAUSE:
+ if (mode != null) {
+ return new PauseCleaningCommand(mode);
+ }
+ break;
+ case CMD_RESUME:
+ if (mode != null) {
+ return new ResumeCleaningCommand(mode);
+ }
+ break;
+ case CMD_STOP:
+ return new StopCleaningCommand();
+ case CMD_CHARGE:
+ return new GoChargingCommand();
+ }
+
+ if (command.startsWith(CMD_SPOT_AREA) && device.hasCapability(DeviceCapability.SPOT_AREA_CLEANING)) {
+ String[] splitted = command.split(":");
+ if (splitted.length == 2 || splitted.length == 3) {
+ int passes = splitted.length == 3 && "x2".equals(splitted[2]) ? 2 : lastDefaultCleaningPasses;
+ List roomIds = new ArrayList<>();
+ for (String id : splitted[1].split(";")) {
+ // We let the user pass in letters as in Ecovacs' app, but the API wants indices
+ if (id.length() == 1 && id.charAt(0) >= 'A' && id.charAt(0) <= 'Z') {
+ roomIds.add(String.valueOf(id.charAt(0) - 'A'));
+ } else {
+ logger.info("{}: Found invalid spot area room ID '{}', ignoring.", serialNumber, id);
+ }
+ }
+ if (!roomIds.isEmpty()) {
+ return new SpotAreaCleaningCommand(roomIds, passes);
+ }
+ } else {
+ logger.info("{}: spotArea command needs to have the form spotArea:[;][;<...roomX>][:x2]",
+ serialNumber);
+ }
+ }
+ if (command.startsWith(CMD_CUSTOM_AREA) && device.hasCapability(DeviceCapability.CUSTOM_AREA_CLEANING)) {
+ String[] splitted = command.split(":");
+ if (splitted.length == 2 || splitted.length == 3) {
+ String coords = splitted[1];
+ int passes = splitted.length == 3 && "x2".equals(splitted[2]) ? 2 : lastDefaultCleaningPasses;
+ String[] splittedAreaDef = coords.split(";");
+ if (splittedAreaDef.length == 4) {
+ return new CustomAreaCleaningCommand(String.join(",", splittedAreaDef), passes);
+ }
+ }
+ logger.info("{}: customArea command needs to have the form customArea:;;;[:x2]",
+ serialNumber);
+ }
+
+ return null;
+ }
+
+ private interface WithDeviceAction {
+ void run(EcovacsDevice device) throws EcovacsApiException, InterruptedException;
+ }
+
+ private void doWithDevice(WithDeviceAction action) {
+ EcovacsDevice device = this.device;
+ if (device == null) {
+ return;
+ }
+ try {
+ action.run(device);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (EcovacsApiException e) {
+ logger.debug("{}: Failed communicating to device, reconnecting", serialNumber, e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ if (e.isAuthFailure) {
+ EcovacsApiHandler apiHandler = getApiHandler();
+ if (apiHandler != null) {
+ apiHandler.onLoginExpired();
+ }
+ // Drop our device instance to make sure we run a full init cycle,
+ // including an API re-login, on reconnection
+ device.disconnect(scheduler);
+ this.device = null;
+ }
+ teardownAndScheduleReconnection();
+ }
+ }
+
+ private @Nullable EcovacsApiHandler getApiHandler() {
+ final Bridge bridge = getBridge();
+ return bridge != null ? (EcovacsApiHandler) bridge.getHandler() : null;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionEntry.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionEntry.java
new file mode 100644
index 0000000000000..cbb30061c2fdd
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionEntry.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2023 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.util;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
+
+/**
+ * A mapping of an binding internal enum value to a user visible (item value) string
+ *
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class StateOptionEntry> {
+ public final T enumValue;
+ public final String value;
+ public final Optional capability;
+
+ public StateOptionEntry(T enumValue, String value) {
+ this(enumValue, value, null);
+ }
+
+ public StateOptionEntry(T enumValue, String value, @Nullable DeviceCapability capability) {
+ this.enumValue = enumValue;
+ this.value = value;
+ this.capability = Optional.ofNullable(capability);
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionMapping.java b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionMapping.java
new file mode 100644
index 0000000000000..eeb5d70cc77ff
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionMapping.java
@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2010-2023 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.util;
+
+import java.util.HashMap;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Danny Baumann - Initial contribution
+ */
+@NonNullByDefault
+public class StateOptionMapping> extends HashMap> {
+ private static final long serialVersionUID = -6828690091106259902L;
+
+ public String getMappedValue(T key) {
+ StateOptionEntry entry = get(key);
+ if (entry != null) {
+ return entry.value;
+ }
+ throw new IllegalArgumentException("No mapping for key " + key);
+ }
+
+ public Optional findMappedEnumValue(String value) {
+ return entrySet().stream().filter(entry -> entry.getValue().value.equals(value)).map(entry -> entry.getKey())
+ .findFirst();
+ }
+
+ @SafeVarargs
+ public static > StateOptionMapping of(StateOptionEntry... entries) {
+ StateOptionMapping map = new StateOptionMapping<>();
+ for (StateOptionEntry entry : entries) {
+ map.put(entry.enumValue, entry);
+ }
+ return map;
+ }
+}
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 0000000000000..4f3f79b635859
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,11 @@
+
+
+
+ binding
+ Ecovacs Binding
+ This is the binding for Ecovacs Deebot vacuum cleaners.
+ cloud
+
+
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/i18n/ecovacs.properties b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/i18n/ecovacs.properties
new file mode 100644
index 0000000000000..4c8227dfd5a53
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/i18n/ecovacs.properties
@@ -0,0 +1,179 @@
+# add-on
+
+addon.ecovacs.name = Ecovacs Binding
+addon.ecovacs.description = This is the binding for Ecovacs Deebot vacuum cleaners.
+
+# thing types
+
+thing-type.ecovacs.ecovacsapi.label = Ecovacs API Account
+thing-type.ecovacs.ecovacsapi.description = The API account
+thing-type.ecovacs.vacuum.label = Ecovacs Vacuum Cleaner
+thing-type.ecovacs.vacuum.description = Represents an Ecovacs vacuum cleaner
+
+# thing types config
+
+thing-type.config.ecovacs.ecovacsapi.continent.label = Continent
+thing-type.config.ecovacs.ecovacsapi.continent.description = Continent the account was registered on. Choose the one you are located in, or "World" if none matches.
+thing-type.config.ecovacs.ecovacsapi.continent.option.ww = World
+thing-type.config.ecovacs.ecovacsapi.continent.option.eu = Europe
+thing-type.config.ecovacs.ecovacsapi.continent.option.na = North America
+thing-type.config.ecovacs.ecovacsapi.continent.option.as = Asia
+thing-type.config.ecovacs.ecovacsapi.email.label = Email
+thing-type.config.ecovacs.ecovacsapi.email.description = Email address for logging in to Ecovacs server
+thing-type.config.ecovacs.ecovacsapi.password.label = Password
+thing-type.config.ecovacs.ecovacsapi.password.description = Password for logging in to Ecovacs server
+thing-type.config.ecovacs.vacuum.refresh.label = Refresh Interval
+thing-type.config.ecovacs.vacuum.refresh.description = Specifies the refresh interval in minutes.
+thing-type.config.ecovacs.vacuum.serialNumber.label = Device Serial Number
+
+# channel group types
+
+channel-group-type.ecovacs.actions.label = Actions
+channel-group-type.ecovacs.consumables.label = Consumables
+channel-group-type.ecovacs.last-clean.label = Last Clean Run
+channel-group-type.ecovacs.settings.label = Settings
+channel-group-type.ecovacs.status.label = Status
+channel-group-type.ecovacs.total-stats.label = Device Lifetime Statistics
+
+# channel types
+
+channel-type.ecovacs.auto-empty.label = Auto Empty
+channel-type.ecovacs.auto-empty.description = Automatically empty dust bin in station
+channel-type.ecovacs.cleaning-passes.label = Cleaning Passes
+channel-type.ecovacs.cleaning-passes.description = Number of cleaning passes used by default (if not overridden in command)
+channel-type.ecovacs.command.label = Command
+channel-type.ecovacs.command.description = Command to execute
+channel-type.ecovacs.command.state.option.clean = Automatic cleaning
+channel-type.ecovacs.command.state.option.pause = Pause cleaning
+channel-type.ecovacs.command.state.option.resume = Resume cleaning
+channel-type.ecovacs.command.state.option.stop = Stop
+channel-type.ecovacs.command.state.option.charge = Go to charge station
+channel-type.ecovacs.continuous-cleaning.label = Continuous Cleaning
+channel-type.ecovacs.continuous-cleaning.description = Automatically resume unfinished cleaning after charging
+channel-type.ecovacs.current-cleaned-area.label = Current Cleaned Area
+channel-type.ecovacs.current-cleaned-area.description = Cleaned area in current clean cycle
+channel-type.ecovacs.current-cleaning-mode.label = Current Cleaning Mode
+channel-type.ecovacs.current-cleaning-mode.description = Mode used in current clean cycle
+channel-type.ecovacs.current-cleaning-spot-definition.label = Current Cleaning Spot
+channel-type.ecovacs.current-cleaning-spot-definition.description = Custom or spot area used in current clean cycle
+channel-type.ecovacs.current-cleaning-time.label = Current Cleaning Time
+channel-type.ecovacs.current-cleaning-time.description = Cleaning time in current clean cycle
+channel-type.ecovacs.dust-filter-lifetime.label = Dust Filter Lifetime
+channel-type.ecovacs.dust-filter-lifetime.description = Remaining life time of dust bin filter in percent
+channel-type.ecovacs.error-code.label = Last Error Code
+channel-type.ecovacs.error-code.description = The numerical value (code) of the last encountered error
+channel-type.ecovacs.error-description.label = Last Error Description
+channel-type.ecovacs.error-description.description = A text describing the last encountered error
+channel-type.ecovacs.last-clean-area.label = Last Cleaned Area
+channel-type.ecovacs.last-clean-area.description = Cleaned area in last completed cleaning run
+channel-type.ecovacs.last-clean-duration.label = Last Cleaning Duration
+channel-type.ecovacs.last-clean-duration.description = Duration of last completed cleaning run
+channel-type.ecovacs.last-clean-map.label = Last Clean Map
+channel-type.ecovacs.last-clean-map.description = Cleaning map for last completed cleaning run
+channel-type.ecovacs.last-clean-mode.label = Last Cleaning Mode
+channel-type.ecovacs.last-clean-mode.description = Operation mode used in last completed cleaning run
+channel-type.ecovacs.last-clean-start.label = Last Cleaning Start
+channel-type.ecovacs.last-clean-start.description = Start time of last completed cleaning run
+channel-type.ecovacs.main-brush-lifetime.label = Main Brush Lifetime
+channel-type.ecovacs.main-brush-lifetime.description = Remaining life time of main brush in percent
+channel-type.ecovacs.other-component-lifetime.label = Other Component Lifetime
+channel-type.ecovacs.other-component-lifetime.description = Remaining time until device maintenance is required in percent
+channel-type.ecovacs.side-brush-lifetime.label = Side Brush Lifetime
+channel-type.ecovacs.side-brush-lifetime.description = Remaining life time of side brush in percent
+channel-type.ecovacs.state.label = State
+channel-type.ecovacs.state.description = Current state
+channel-type.ecovacs.state.state.option.cleaning = Cleaning
+channel-type.ecovacs.state.state.option.pause = Paused
+channel-type.ecovacs.state.state.option.stop = Stopped
+channel-type.ecovacs.state.state.option.washing = Washing the cleaning pad
+channel-type.ecovacs.state.state.option.drying = Drying the cleaning pad
+channel-type.ecovacs.state.state.option.returning = Going to charge station
+channel-type.ecovacs.state.state.option.charging = Charging
+channel-type.ecovacs.state.state.option.idle = Idle
+channel-type.ecovacs.suction-power.label = Cleaning Power Level
+channel-type.ecovacs.suction-power.description = Amount of suction power to be used while cleaning
+channel-type.ecovacs.suction-power.state.option.silent = Silent
+channel-type.ecovacs.suction-power.state.option.normal = Normal
+channel-type.ecovacs.suction-power.state.option.high = Maximum
+channel-type.ecovacs.suction-power.state.option.higher = Maximum+
+channel-type.ecovacs.total-clean-runs.label = Total Clean Runs
+channel-type.ecovacs.total-clean-runs.description = Number of cleaning runs in device life time
+channel-type.ecovacs.total-cleaned-area.label = Total Cleaned Area
+channel-type.ecovacs.total-cleaned-area.description = Cleaned area in device life time
+channel-type.ecovacs.total-cleaning-time.label = Total Cleaning Time
+channel-type.ecovacs.total-cleaning-time.description = Cleaning time in device life time
+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.label = Mopping Water Amount
+channel-type.ecovacs.water-amount.state.option.low = Low
+channel-type.ecovacs.water-amount.state.option.medium = Medium
+channel-type.ecovacs.water-amount.state.option.high = High
+channel-type.ecovacs.water-amount.state.option.veryhigh = Very high
+channel-type.ecovacs.water-system-present.label = Water System Present
+channel-type.ecovacs.water-system-present.description = Water plate with mop attached to device?
+channel-type.ecovacs.wifi-rssi.label = Wi-Fi Signal Strength
+channel-type.ecovacs.wifi-rssi.description = Received signal strength indicator for Wi-Fi
+
+# cleaning modes
+
+ecovacs.cleaning-mode.auto = Automatic
+ecovacs.cleaning-mode.edge = Edge cleaning
+ecovacs.cleaning-mode.spot = Spot cleaning
+ecovacs.cleaning-mode.spotArea = Spot area cleaning
+ecovacs.cleaning-mode.customArea = Custom area cleaning
+ecovacs.cleaning-mode.singleRoom = Single room cleaning
+
+# error codes
+
+ecovacs.vacuum.error-code.0 = No error
+ecovacs.vacuum.error-code.3 = Authentication error
+ecovacs.vacuum.error-code.7 = Log data was not found
+ecovacs.vacuum.error-code.100 = No error
+ecovacs.vacuum.error-code.101 = Low battery
+ecovacs.vacuum.error-code.102 = Robot is off the floor
+ecovacs.vacuum.error-code.103 = Driving wheel malfunction
+ecovacs.vacuum.error-code.104 = Excess dust on the anti-drop sensors
+ecovacs.vacuum.error-code.105 = Robot is stuck
+ecovacs.vacuum.error-code.106 = Side brushes have expired
+ecovacs.vacuum.error-code.107 = Dust case filter expired
+ecovacs.vacuum.error-code.108 = Side brushes are tangled
+ecovacs.vacuum.error-code.109 = Main brush is tangled
+ecovacs.vacuum.error-code.110 = Dust bin not installed
+ecovacs.vacuum.error-code.111 = Bump sensor stuck
+ecovacs.vacuum.error-code.112 = Laser distance sensor malfunction
+ecovacs.vacuum.error-code.113 = Main brush has expired
+ecovacs.vacuum.error-code.114 = Dust bin full
+ecovacs.vacuum.error-code.115 = Battery error
+ecovacs.vacuum.error-code.116 = Forward looking error
+ecovacs.vacuum.error-code.117 = Gyroscope error
+ecovacs.vacuum.error-code.118 = Strainer blocked
+ecovacs.vacuum.error-code.119 = Fan error
+ecovacs.vacuum.error-code.120 = Water box error
+ecovacs.vacuum.error-code.201 = Air filter removed
+ecovacs.vacuum.error-code.202 = Ultrasonic component error
+ecovacs.vacuum.error-code.203 = Small wheel error
+ecovacs.vacuum.error-code.204 = Wheel is blocked
+ecovacs.vacuum.error-code.205 = Ion sterilization exhausted
+ecovacs.vacuum.error-code.206 = Ion sterilization error
+ecovacs.vacuum.error-code.207 = Ion sterilization fault
+ecovacs.vacuum.error-code.404 = Recipient unavailable
+ecovacs.vacuum.error-code.500 = Request timeout
+ecovacs.vacuum.error-code.601 = AIVI side error
+ecovacs.vacuum.error-code.602 = AIVI roll error
+ecovacs.vacuum.error-code.unknown = Unknown error ({0})
+
+# thing status descriptions
+
+offline.config-error-no-country = A country needs to be set in the openHAB regional settings.
+offline.config-error-no-serial = Serial number is missing in the configuration of this device.
+
+# actions
+
+playSoundActionLabel = play sound
+playSoundActionDesc = Play a sound through the device speaker.
+actionInputSoundTypeLabel = Sound Type
+actionInputSoundTypeDesc = The type of sound to play.
+actionInputSoundIdLabel = Sound ID
+actionInputSoundIdDesc = The numeric ID of the sound to play.
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/bridge.xml b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/bridge.xml
new file mode 100644
index 0000000000000..9c41dba2ea829
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/bridge.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ The API account
+
+
+
+
+ email
+ Email address for logging in to Ecovacs server
+ true
+
+
+
+ password
+ Password for logging in to Ecovacs server
+ true
+
+
+
+ Continent the account was registered on. Choose the one you are located in, or "World" if none matches.
+ ww
+
+
+
+
+
+
+
+
+
+
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 0000000000000..3bd52094f4598
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+ Represents an Ecovacs vacuum cleaner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ Specifies the refresh interval in minutes.
+ Minutes
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Current state
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Command to execute
+
+
+
+
+
+
+
+
+
+
+
+
+ String
+
+ Mode used in current clean cycle
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Number:Time
+
+ Cleaning time in current clean cycle
+
+
+
+
+ Number:Area
+
+ Cleaned area in current clean cycle
+
+
+
+
+ String
+
+ Custom or spot area used in current clean cycle
+
+
+
+
+ Number:Time
+
+ Cleaning time in device life time
+
+
+
+
+ Number:Area
+
+ Cleaned area in device life time
+
+
+
+
+ Number
+
+ Number of cleaning runs in device life time
+
+
+
+
+ DateTime
+
+ Start time of last completed cleaning run
+
+
+
+
+ Number:Time
+
+ Duration of last completed cleaning run
+
+
+
+
+ Number:Area
+
+ Cleaned area in last completed cleaning run
+
+
+
+
+ String
+
+ Operation mode used in last completed cleaning run
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image
+
+ Cleaning map for last completed cleaning run
+
+
+
+
+ Switch
+
+ Automatically empty dust bin in station
+
+
+
+ Number
+
+ Number of cleaning passes used by default (if not overridden in command)
+
+
+
+
+ Switch
+
+ Automatically resume unfinished cleaning after charging
+
+
+
+ String
+
+ Amount of suction power to be used while cleaning
+
+
+
+
+
+
+
+
+
+
+
+ Switch
+
+ Enable the True Detect 3D object recognition technology
+
+
+
+ Switch
+
+ Water plate with mop attached to device?
+
+
+
+
+ String
+
+
+
+
+
+
+
+
+
+
+
+
+ Number:Power
+
+ Received signal strength indicator for Wi-Fi
+ QualityOfService
+
+
+
+
+ Number:Dimensionless
+
+ Remaining life time of main brush in percent
+
+
+
+
+ Number:Dimensionless
+
+ Remaining life time of side brush in percent
+
+
+
+
+ Number:Dimensionless
+
+ Remaining life time of dust bin filter in percent
+
+
+
+
+ Number:Dimensionless
+
+ Remaining time until device maintenance is required in percent
+
+
+
+
+ Dimmer
+
+ Volume level of voice reports
+
+
+
+
+ Number
+
+ The numerical value (code) of the last encountered error
+
+
+
+
+ String
+
+ A text describing the last encountered error
+
+
+
+
diff --git a/bundles/org.openhab.binding.ecovacs/src/main/resources/devices/supported_device_list.json b/bundles/org.openhab.binding.ecovacs/src/main/resources/devices/supported_device_list.json
new file mode 100644
index 0000000000000..36985c9325559
--- /dev/null
+++ b/bundles/org.openhab.binding.ecovacs/src/main/resources/devices/supported_device_list.json
@@ -0,0 +1,541 @@
+[
+ {
+ "modelName": "DEEBOT 600 Series",
+ "deviceClass": "dl8fht",
+ "protoVersion": "xml",
+ "usesMqtt": true,
+ "capabilities": [
+ "mopping_system",
+ "main_brush",
+ "clean_speed_control"
+ ]
+ },
+ {
+ "modelName": "DEEBOT OZMO 601",
+ "deviceClass": "159",
+ "deviceClassLink": "dl8fht"
+ },
+ {
+ "modelName": "DEEBOT 661",
+ "deviceClass": "16wdph",
+ "deviceClassLink": "dl8fht"
+ },
+
+ {
+ "modelName": "DEEBOT OZMO 610 Series",
+ "deviceClass": "130",
+ "protoVersion": "xml",
+ "usesMqtt": false,
+ "capabilities": [
+ "mopping_system",
+ "main_brush",
+ "single_room_cleaning"
+ ]
+ },
+
+ {
+ "modelName": "DEEBOT 710",
+ "deviceClass": "uv242z",
+ "protoVersion": "xml",
+ "usesMqtt": true,
+ "capabilities": [
+ "main_brush",
+ "clean_speed_control"
+ ]
+ },
+ {
+ "modelName": "DEEBOT 711",
+ "deviceClass": "jr3pqa",
+ "deviceClassLink": "uv242z"
+ },
+ {
+ "modelName": "DEEBOT 711s",
+ "deviceClass": "d0cnel",
+ "deviceClassLink": "uv242z"
+ },
+ {
+ "modelName": "DEEBOT 715",
+ "deviceClass": "eyi9jv",
+ "deviceClassLink": "uv242z"
+ },
+
+ {
+ "modelName": "DEEBOT 900 Series",
+ "deviceClass": "ls1ok3",
+ "protoVersion": "xml",
+ "usesMqtt": true,
+ "capabilities": [
+ "main_brush",
+ "spot_area_cleaning",
+ "custom_area_cleaning",
+ "clean_speed_control",
+ "mapping"
+ ]
+ },
+
+ {
+ "modelName": "DEEBOT OZMO 900 Series",
+ "deviceClass": "y79a7u",
+ "protoVersion": "xml",
+ "usesMqtt": true,
+ "capabilities": [
+ "mopping_system",
+ "main_brush",
+ "spot_area_cleaning",
+ "custom_area_cleaning",
+ "clean_speed_control",
+ "mapping"
+ ]
+ },
+ {
+ "modelName": "DEEBOT OZMO 905",
+ "deviceClass": "2pv572",
+ "deviceClassLink": "y79a7u"
+ },
+
+ {
+ "modelName": "DEEBOT OZMO/PRO 930 Series",
+ "deviceClass": "115",
+ "protoVersion": "xml",
+ "usesMqtt": false,
+ "capabilities": [
+ "mopping_system",
+ "main_brush",
+ "spot_area_cleaning",
+ "custom_area_cleaning",
+ "mapping"
+ ]
+ },
+
+ {
+ "modelName": "DEEBOT Slim2 Series",
+ "deviceClass": "123",
+ "protoVersion": "xml",
+ "usesMqtt": false,
+ "capabilities": [
+ ]
+ },
+
+ {
+ "modelName": "DEEBOT OZMO Slim10 Series",
+ "deviceClass": "02uwxm",
+ "protoVersion": "xml",
+ "usesMqtt": true,
+ "capabilities": [
+ "mopping_system",
+ "clean_speed_control"
+ ]
+ },
+
+ {
+ "modelName": "DEEBOT OZMO 950 Series",
+ "deviceClass": "yna5xi",
+ "protoVersion": "json",
+ "usesMqtt": true,
+ "capabilities": [
+ "mopping_system",
+ "main_brush",
+ "spot_area_cleaning",
+ "custom_area_cleaning",
+ "clean_speed_control",
+ "voice_reporting",
+ "read_network_info",
+ "mapping"
+ ]
+ },
+ {
+ "modelName": "DEEBOT OZMO 920",
+ "deviceClass": "vi829v",
+ "deviceClassLink": "yna5xi"
+ },
+ {
+ "modelName": "DEEBOT OZMO T5",
+ "deviceClass": "9rft3c",
+ "deviceClassLink": "yna5xi"
+ },
+
+ {
+ "modelName": "DEEBOT N8",
+ "deviceClass": "n6cwdb",
+ "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",
+ "mapping"
+ ]
+ },
+ {
+ "modelName": "DEEBOT N3 MAX",
+ "deviceClass": "jffnlf",
+ "deviceClassLink": "n6cwdb"
+ },
+ {
+ "modelName": "DEEBOT N7",
+ "deviceClass": "r5zxjr",
+ "deviceClassLink": "n6cwdb"
+ },
+ {
+ "modelName": "DEEBOT N8",
+ "deviceClass": "r5y7re",
+ "deviceClassLink": "n6cwdb"
+ },
+ {
+ "modelName": "DEEBOT N8",
+ "deviceClass": "ty84oi",
+ "deviceClassLink": "n6cwdb"
+ },
+ {
+ "modelName": "DEEBOT N8",
+ "deviceClass": "36xnxf",
+ "deviceClassLink": "n6cwdb"
+ },
+ {
+ "modelName": "DEEBOT N8 Neo",
+ "deviceClass": "z0gd1j",
+ "deviceClassLink": "n6cwdb"
+ },
+
+ {
+ "modelName": "DEEBOT N8+",
+ "deviceClass": "b2jqs4",
+ "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",
+ "auto_empty_station",
+ "mapping"
+ ]
+ },
+ {
+ "modelName": "DEEBOT N8+",
+ "deviceClass": "7bryc5",
+ "deviceClassLink": "b2jqs4"
+ },
+
+ {
+ "modelName": "DEEBOT OZMO T8",
+ "deviceClass": "h18jkh",
+ "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"
+ ]
+ },
+ {
+ "modelName": "DEEBOT OZMO T8",
+ "deviceClass": "b742vd",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT OZMO T8 PURE",
+ "deviceClass": "0bdtzz",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT OZMO T8 AIVI",
+ "deviceClass": "x5d34r",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T8",
+ "deviceClass": "wgxm70",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T8 AIVI",
+ "deviceClass": "bs40nz",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T8 AIVI",
+ "deviceClass": "5089oy",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T8 MAX",
+ "deviceClass": "a1nNMoAGAsH",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T8 POWER",
+ "deviceClass": "no61kx",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T9",
+ "deviceClass": "ucn2xe",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT T9",
+ "deviceClass": "ipohi5",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT N8 PRO",
+ "deviceClass": "snxbvc",
+ "deviceClassLink": "h18jkh"
+ },
+ {
+ "modelName": "DEEBOT N8 PRO",
+ "deviceClass": "yu362x",
+ "deviceClassLink": "h18jkh"
+ },
+
+ {
+ "modelName": "DEEBOT OZMO T8+",
+ "deviceClass": "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"
+ ]
+ },
+ {
+ "modelName": "DEEBOT OZMO T8+",
+ "deviceClass": "55aiho",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT OZMO T8 AIVI +",
+ "deviceClass": "tpnwyu",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT OZMO T8 AIVI +",
+ "deviceClass": "34vhpm",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT OZMO T8 AIVI +",
+ "deviceClass": "w16crm",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT T8 AIVI +",
+ "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",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT N8 PRO+",
+ "deviceClass": "ifbw08",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT N9+",
+ "deviceClass": "a7lhb1",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT N9+",
+ "deviceClass": "c2of2s",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1",
+ "deviceClass": "3yqsch",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT T10",
+ "deviceClass": "jtmf04",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT T10 PLUS",
+ "deviceClass": "rss8xk",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT T10 PLUS",
+ "deviceClass": "p95mgv",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT T10 TURBO",
+ "deviceClass": "9s1s80",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT T10 OMNI",
+ "deviceClass": "lx3j7m",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1 OMNI",
+ "deviceClass": "8bja83",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1 OMNI",
+ "deviceClass": "1b23du",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1 OMNI",
+ "deviceClass": "1vxt52",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1 TURBO",
+ "deviceClass": "2o4lnm",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1 PLUS",
+ "deviceClass": "n4gstt",
+ "deviceClassLink": "fqxoiu"
+ },
+ {
+ "modelName": "DEEBOT X1e OMNI",
+ "deviceClass": "bro5wu",
+ "deviceClassLink": "fqxoiu"
+ },
+
+ {
+ "modelName": "DEEBOT U2",
+ "deviceClass": "ipzjy0",
+ "protoVersion": "json",
+ "usesMqtt": true,
+ "capabilities": [
+ "mopping_system",
+ "main_brush",
+ "clean_speed_control",
+ "voice_reporting",
+ "read_network_info"
+ ]
+ },
+ {
+ "modelName": "DEEBOT U2",
+ "deviceClass": "rvo6ev",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2",
+ "deviceClass": "wlqdkp",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "nq9yhl",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "y2qy3m",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "7j1tu6",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "ts2ofl",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "c0lwyn",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "d4v1pm",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "u6eqoa",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "12baap",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 PRO",
+ "deviceClass": "u4h1uk",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 POWER",
+ "deviceClass": "1zqysa",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 POWER",
+ "deviceClass": "chmi0g",
+ "deviceClassLink": "ipzjy0"
+ },
+ {
+ "modelName": "DEEBOT U2 SE",
+ "deviceClass": "zjna8m",
+ "deviceClassLink": "ipzjy0"
+ }
+]
diff --git a/bundles/pom.xml b/bundles/pom.xml
index ec4e0c3193e2d..07c78bca987f4 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -116,6 +116,7 @@
org.openhab.binding.echonetliteorg.openhab.binding.ecobeeorg.openhab.binding.ecotouch
+ org.openhab.binding.ecovacsorg.openhab.binding.ecowattorg.openhab.binding.ekeyorg.openhab.binding.electroluxair