]> git.basschouten.com Git - openhab-addons.git/commitdiff
[ecovacs] Initial contribution (#12231)
authormaniac103 <dannybaumann@web.de>
Tue, 21 Mar 2023 10:05:53 +0000 (11:05 +0100)
committerGitHub <noreply@github.com>
Tue, 21 Mar 2023 10:05:53 +0000 (11:05 +0100)
* [ecovacs] Initial contribution

Add initial version of a binding for vacuum cleaners made by Ecovacs.

Signed-off-by: Danny Baumann <dannybaumann@web.de>
128 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.ecovacs/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/README.md [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsDynamicStateDescriptionProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/EcovacsHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/action/EcovacsVacuumActions.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApi.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsApiException.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/EcovacsDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractAreaCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/AbstractNoResponseCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/CustomAreaCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/EmptyDustbinCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetActiveMapIdCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetBatteryInfoCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetChargeStateCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanLogsCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetCleanStateCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetComponentLifeSpanCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetContinuousCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDefaultCleanPassesCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetDustbinAutoEmptyCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetErrorCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetFirmwareVersionCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMapSpotAreasWithMapIdCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetMoppingWaterAmountCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetNetworkInfoCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetSuctionPowerCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTotalStatsCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetTrueDetectCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetVolumeCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GetWaterSystemPresentCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/GoChargingCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/IotDeviceCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PauseCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/PlaySoundCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/ResumeCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetContinuousCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDefaultCleanPassesCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetDustbinAutoEmptyCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetMoppingWaterAmountCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetSuctionPowerCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetTrueDetectCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SetVolumeCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/SpotAreaCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StartAutoCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/commands/StopCleaningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/DeviceDescription.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiImpl.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsApiUrlFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsIotMqDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/EcovacsXmppDevice.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/JsonReportParser.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/PortalTodo.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ProtocolVersion.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/ReportParser.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/XmlReportParser.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalAuthRequestParameter.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalCleanLogsRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotCommandRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalIotProductRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/request/portal/PortalLoginRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/BatteryReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CachedMapInfoReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ChargeReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/CleanReportV2.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ComponentLifeSpanReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/DefaultCleanCountReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EnabledStateReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/ErrorReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/EventReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/MapSetReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/NetworkInfoReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SleepReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/SpeedReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/StatsReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/json/WaterInfoReport.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/CleaningInfo.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/DeviceInfo.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/deviceapi/xml/WaterSystemInfo.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AccessData.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/AuthCode.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/main/ResponseWrapper.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalIotCommandResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/AbstractPortalResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Device.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/IotProduct.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalCleanLogsResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalDeviceResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandJsonResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotCommandXmlResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalIotProductResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/PortalLoginResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/impl/dto/response/portal/Service.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/ChargeMode.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanLogRecord.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/CleanMode.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/Component.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/DeviceCapability.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/MoppingWaterAmount.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/NetworkInfo.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SpotAreaType.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/model/SuctionPower.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/DataParsingException.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/MD5Util.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/SchedulerTask.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/api/util/XPathUtils.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsApiConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/config/EcovacsVacuumConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/discovery/EcovacsDeviceDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsApiHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/handler/EcovacsVacuumHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionEntry.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/java/org/openhab/binding/ecovacs/internal/util/StateOptionMapping.java [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/i18n/ecovacs.properties [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/bridge.xml [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.ecovacs/src/main/resources/devices/supported_device_list.json [new file with mode: 0644]
bundles/pom.xml

index 5be07e4077702eaf5523a3bc1a5ab4acda5fba43..c2270388ba127fcd34bd403c03a2dafb985ab009 100644 (file)
@@ -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
index d385f693c87fa4c8ec7e8608294df6a31bfc6f7a..d968a3512444d80a7bd6ad2bc575e4d2aaccb686 100644 (file)
       <artifactId>org.openhab.binding.ecotouch</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.ecovacs</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.ecowatt</artifactId>
diff --git a/bundles/org.openhab.binding.ecovacs/NOTICE b/bundles/org.openhab.binding.ecovacs/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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 (file)
index 0000000..98284ba
--- /dev/null
@@ -0,0 +1,175 @@
+# Ecovacs Binding
+
+This binding provides integration for vacuum cleaning / mopping robots made by Ecovacs (<https://www.ecovacs.com/>).
+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.            | <ul><li>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.</li><li>Format: `spotArea:<room IDs>`, 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`.</li><li>If you want to run 2 clean passes, amend `:x2` to the command, e.g. `spotArea:A;C;B:x2`.</li></ul> |
+| `customArea` | Start cleaning specific areas.            | <ul><li>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.</li><li>Format: `customArea:<x1>;<y1>;<x2>;<y2>, where the parameters are coordinates (in mm) relative to the map.</li><li>The coordinates can be obtained from the `current-cleaning-spot-definition` channel when starting a custom area run from the app.</li><li>If you want to run 2 clean passes, amend `:x2` to the command, e.g. `customArea:100;100;1000;1000:x2`.</li></ul> |
+| `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 (file)
index 0000000..fe9554e
--- /dev/null
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.0.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.ecovacs</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Ecovacs Binding</name>
+  <properties>
+    <smack.version>4.3.3</smack.version>
+  </properties>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.igniterealtime.smack</groupId>
+      <artifactId>smack-tcp</artifactId>
+      <version>${smack.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.igniterealtime.smack</groupId>
+      <artifactId>smack-im</artifactId>
+      <version>${smack.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.igniterealtime.smack</groupId>
+      <artifactId>smack-extensions</artifactId>
+      <version>${smack.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.igniterealtime.smack</groupId>
+      <artifactId>smack-java7</artifactId>
+      <version>${smack.version}</version>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.igniterealtime.smack</groupId>
+      <artifactId>smack-resolver-javax</artifactId>
+      <version>${smack.version}</version>
+    </dependency>
+  </dependencies>
+</project>
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 (file)
index 0000000..ee9c312
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.ecovacs-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+       <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+       <feature name="openhab-binding-ecovacs" description="Ecovacs Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <feature dependency="true">openhab.tp-hivemqclient</feature>
+               <bundle dependency="true">mvn:org.igniterealtime.smack/smack-tcp/4.3.3</bundle>
+               <bundle dependency="true">mvn:org.jxmpp/jxmpp-core/0.6.3</bundle>
+               <bundle dependency="true">mvn:org.jxmpp/jxmpp-jid/0.6.3</bundle>
+               <bundle dependency="true">mvn:org.jxmpp/jxmpp-util-cache/0.6.3</bundle>
+               <bundle dependency="true">mvn:org.minidns/minidns-core/0.3.3</bundle>
+               <bundle dependency="true">mvn:org.igniterealtime.smack/smack-core/4.3.3</bundle>
+               <bundle dependency="true">mvn:org.igniterealtime.smack/smack-im/4.3.3</bundle>
+               <bundle dependency="true">mvn:org.igniterealtime.smack/smack-extensions/4.3.3</bundle>
+               <bundle dependency="true">mvn:org.apache.servicemix.bundles/org.apache.servicemix.bundles.xpp3/1.1.4c_7</bundle>
+               <bundle start-level="80">mvn:org.igniterealtime.smack/smack-resolver-javax/4.3.3</bundle>
+               <bundle start-level="80">mvn:org.igniterealtime.smack/smack-java7/4.3.3</bundle>
+               <bundle start-level="80">mvn:org.igniterealtime.smack/smack-sasl-javax/4.3.3</bundle>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.ecovacs/${project.version}</bundle>
+       </feature>
+</features>
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 (file)
index 0000000..a805ea8
--- /dev/null
@@ -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<CleanMode> CLEAN_MODE_MAPPING = StateOptionMapping.<CleanMode> of(
+            new StateOptionEntry<CleanMode>(CleanMode.AUTO, "auto"),
+            new StateOptionEntry<CleanMode>(CleanMode.EDGE, "edge", DeviceCapability.EDGE_CLEANING),
+            new StateOptionEntry<CleanMode>(CleanMode.SPOT, "spot", DeviceCapability.SPOT_CLEANING),
+            new StateOptionEntry<CleanMode>(CleanMode.SPOT_AREA, "spotArea", DeviceCapability.SPOT_AREA_CLEANING),
+            new StateOptionEntry<CleanMode>(CleanMode.CUSTOM_AREA, "customArea", DeviceCapability.CUSTOM_AREA_CLEANING),
+            new StateOptionEntry<CleanMode>(CleanMode.SINGLE_ROOM, "singleRoom", DeviceCapability.SINGLE_ROOM_CLEANING),
+            new StateOptionEntry<CleanMode>(CleanMode.PAUSE, "pause"),
+            new StateOptionEntry<CleanMode>(CleanMode.STOP, "stop"),
+            new StateOptionEntry<CleanMode>(CleanMode.WASHING, "washing"),
+            new StateOptionEntry<CleanMode>(CleanMode.DRYING, "drying"),
+            new StateOptionEntry<CleanMode>(CleanMode.RETURNING, "returning"));
+
+    public static final StateOptionMapping<MoppingWaterAmount> WATER_AMOUNT_MAPPING = StateOptionMapping
+            .<MoppingWaterAmount> of(new StateOptionEntry<MoppingWaterAmount>(MoppingWaterAmount.LOW, "low"),
+                    new StateOptionEntry<MoppingWaterAmount>(MoppingWaterAmount.MEDIUM, "medium"),
+                    new StateOptionEntry<MoppingWaterAmount>(MoppingWaterAmount.HIGH, "high"),
+                    new StateOptionEntry<MoppingWaterAmount>(MoppingWaterAmount.VERY_HIGH, "veryhigh"));
+
+    public static final StateOptionMapping<SuctionPower> SUCTION_POWER_MAPPING = StateOptionMapping.<SuctionPower> of(
+            new StateOptionEntry<SuctionPower>(SuctionPower.SILENT, "silent",
+                    DeviceCapability.EXTENDED_CLEAN_SPEED_CONTROL),
+            new StateOptionEntry<SuctionPower>(SuctionPower.NORMAL, "normal"),
+            new StateOptionEntry<SuctionPower>(SuctionPower.HIGH, "high"), new StateOptionEntry<SuctionPower>(
+                    SuctionPower.HIGHER, "higher", DeviceCapability.EXTENDED_CLEAN_SPEED_CONTROL));
+
+    public static final StateOptionMapping<SoundType> SOUND_TYPE_MAPPING = StateOptionMapping.<SoundType> of(
+            new StateOptionEntry<SoundType>(SoundType.BEEP, "beep"),
+            new StateOptionEntry<SoundType>(SoundType.I_AM_HERE, "iAmHere"),
+            new StateOptionEntry<SoundType>(SoundType.STARTUP, "startup"),
+            new StateOptionEntry<SoundType>(SoundType.SUSPENDED, "suspended"),
+            new StateOptionEntry<SoundType>(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 (file)
index 0000000..a7a1190
--- /dev/null
@@ -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<StateOption> localizedStateOptions(List<StateOption> 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 (file)
index 0000000..dee2533
--- /dev/null
@@ -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<ThingTypeUID> 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 (file)
index 0000000..1d0a941
--- /dev/null
@@ -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<PlaySoundCommand.SoundType> 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 (file)
index 0000000..b226bb6
--- /dev/null
@@ -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<EcovacsDevice> 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 (file)
index 0000000..9019c68
--- /dev/null
@@ -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 (file)
index 0000000..aaf8cf2
--- /dev/null
@@ -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 (file)
index 0000000..e74e3f4
--- /dev/null
@@ -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<String> 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> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException;
+
+    List<CleanLogRecord> 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 (file)
index 0000000..fb319d7
--- /dev/null
@@ -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 (file)
index 0000000..aa796be
--- /dev/null
@@ -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<CleanMode> 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<String> 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 (file)
index 0000000..d0f6fff
--- /dev/null
@@ -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<AbstractNoResponseCommand.Nothing> {
+    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 (file)
index 0000000..bb54809
--- /dev/null
@@ -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 (file)
index 0000000..6e097d7
--- /dev/null
@@ -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 (file)
index 0000000..943846a
--- /dev/null
@@ -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<String> {
+    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 (file)
index 0000000..9b5b0cd
--- /dev/null
@@ -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<Integer> {
+    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 (file)
index 0000000..ebf79af
--- /dev/null
@@ -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<ChargeMode> {
+    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 (file)
index 0000000..5d2f419
--- /dev/null
@@ -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<List<CleanLogRecord>> {
+    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<CleanLogRecord> 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<CleanLogRecord> 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 (file)
index 0000000..66b23e6
--- /dev/null
@@ -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<CleanMode> {
+    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 (file)
index 0000000..92dbaed
--- /dev/null
@@ -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<Integer> {
+    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<List<ComponentLifeSpanReport>>() {
+            }.getType();
+            try {
+                List<ComponentLifeSpanReport> 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 (file)
index 0000000..7c12cce
--- /dev/null
@@ -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<Boolean> {
+    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 (file)
index 0000000..1de4222
--- /dev/null
@@ -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<Integer> {
+    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 (file)
index 0000000..e44f844
--- /dev/null
@@ -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<Boolean> {
+    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 (file)
index 0000000..fac4743
--- /dev/null
@@ -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<Optional<Integer>> {
+    public GetErrorCommand() {
+        super();
+    }
+
+    @Override
+    public String getName(ProtocolVersion version) {
+        return version == ProtocolVersion.XML ? "GetError" : "getError";
+    }
+
+    @Override
+    public Optional<Integer> 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 (file)
index 0000000..375eeb8
--- /dev/null
@@ -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<String> {
+    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 (file)
index 0000000..496fbb4
--- /dev/null
@@ -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<List<String>> {
+    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<String> 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<String> 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 (file)
index 0000000..e300e98
--- /dev/null
@@ -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<MoppingWaterAmount> {
+    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 (file)
index 0000000..4c23ca3
--- /dev/null
@@ -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<NetworkInfo> {
+    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 (file)
index 0000000..45b86d8
--- /dev/null
@@ -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<SuctionPower> {
+    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 (file)
index 0000000..04179c1
--- /dev/null
@@ -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<GetTotalStatsCommand.TotalStats> {
+    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 (file)
index 0000000..f3f5416
--- /dev/null
@@ -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<Boolean> {
+    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 (file)
index 0000000..fc75720
--- /dev/null
@@ -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<Integer> {
+    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 (file)
index 0000000..55e5d32
--- /dev/null
@@ -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<Boolean> {
+    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 (file)
index 0000000..0aac16d
--- /dev/null
@@ -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 (file)
index 0000000..611b2bd
--- /dev/null
@@ -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<RESPONSETYPE> {
+    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 (file)
index 0000000..898784a
--- /dev/null
@@ -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 (file)
index 0000000..3672529
--- /dev/null
@@ -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 (file)
index 0000000..1ff6dda
--- /dev/null
@@ -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 (file)
index 0000000..edadfd8
--- /dev/null
@@ -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 (file)
index 0000000..b58a3c8
--- /dev/null
@@ -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 (file)
index 0000000..ea4c4ba
--- /dev/null
@@ -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 (file)
index 0000000..5488f21
--- /dev/null
@@ -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 (file)
index 0000000..f498eec
--- /dev/null
@@ -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 (file)
index 0000000..3d977e5
--- /dev/null
@@ -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 (file)
index 0000000..8cf64ba
--- /dev/null
@@ -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 (file)
index 0000000..9a693cf
--- /dev/null
@@ -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<String> 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 (file)
index 0000000..999a32d
--- /dev/null
@@ -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 (file)
index 0000000..369c407
--- /dev/null
@@ -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 (file)
index 0000000..c8792ab
--- /dev/null
@@ -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<DeviceCapability> capabilities;
+
+    public DeviceDescription(String modelName, String deviceClass, @Nullable String deviceClassLink,
+            ProtocolVersion protoVersion, boolean usesMqtt, Set<DeviceCapability> 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 (file)
index 0000000..2cf1f78
--- /dev/null
@@ -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<String, String> loginParameters = new HashMap<>();
+        loginParameters.put("account", configuration.getUsername());
+        loginParameters.put("password", MD5Util.getMD5Hash(configuration.getPassword()));
+        loginParameters.put("requestId", MD5Util.getMD5Hash(String.valueOf(System.currentTimeMillis())));
+        loginParameters.put("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<ResponseWrapper<AccessData>>() {
+        }.getType();
+        return handleResponseWrapper(gson.fromJson(loginResponse.getContentAsString(), responseType));
+    }
+
+    private AuthCode getAuthCode(AccessData accessData) throws EcovacsApiException, InterruptedException {
+        HashMap<String, String> 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<ResponseWrapper<AuthCode>>() {
+        }.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<EcovacsDevice> getDevices() throws EcovacsApiException, InterruptedException {
+        List<DeviceDescription> descriptions = getSupportedDeviceList();
+        List<IotProduct> products = null;
+        List<EcovacsDevice> devices = new ArrayList<>();
+        for (Device dev : getDeviceList()) {
+            Optional<DeviceDescription> 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<DeviceDescription> 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<List<DeviceDescription>>() {
+        }.getType();
+        List<DeviceDescription> descs = gson.fromJson(reader, type);
+        return descs.stream().map(desc -> {
+            final DeviceDescription result;
+            if (desc.deviceClassLink != null) {
+                Optional<DeviceDescription> 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<Device> 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<Device> devices = handleResponse(deviceResponse, PortalDeviceResponse.class).getDevices();
+        return devices != null ? devices : Collections.emptyList();
+    }
+
+    private List<IotProduct> 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<IotProduct> products = handleResponse(productResponse, PortalIotProductResponse.class).getProducts();
+        return products != null ? products : Collections.emptyList();
+    }
+
+    public <T> T sendIotCommand(Device device, DeviceDescription desc, IotDeviceCommand<T> 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<PortalCleanLogsResponse.LogRecord> 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> T handleResponseWrapper(@Nullable ResponseWrapper<T> 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> T handleResponse(ContentResponse response, Class<T> 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<String, String> requestSpecificParameters) {
+        HashMap<String, String> 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 (file)
index 0000000..745bf37
--- /dev/null
@@ -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 (file)
index 0000000..fe57fd3
--- /dev/null
@@ -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> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException {
+        return api.sendIotCommand(device, desc, command);
+    }
+
+    @Override
+    public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException {
+        Stream<CleanLogRecord> 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 (file)
index 0000000..193ed6a
--- /dev/null
@@ -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> T sendCommand(IotDeviceCommand<T> 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<CleanLogRecord> 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<String, CommandResponseHolder> 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<String> 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 (file)
index 0000000..0433ea4
--- /dev/null
@@ -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> T payloadAs(JsonResponsePayloadWrapper response, Class<T> 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 (file)
index 0000000..1398798
--- /dev/null
@@ -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 (file)
index 0000000..10486b3
--- /dev/null
@@ -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 (file)
index 0000000..53db9ee
--- /dev/null
@@ -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 (file)
index 0000000..be9f7a0
--- /dev/null
@@ -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:
+                // <ctl td='CleanReport'><clean type='auto' speed='standard' st='s' rsn='a'/></ctl>
+                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:
+                // <ctl td='CleanRptBgdata' ts='1643044172' Battery='102' CleanID='1333688018' iCleanID='0497265223'
+                // MapID='1430814334' rsn='a' IsFrmCharger='1' CleanType='auto' Speed='standard' OnOffRag='0'
+                // WorkMode='s' Spray='2' WorkArea='002'/>
+                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 (file)
index 0000000..a7b364b
--- /dev/null
@@ -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 (file)
index 0000000..036d7b0
--- /dev/null
@@ -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 (file)
index 0000000..0d41c9d
--- /dev/null
@@ -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 (file)
index 0000000..ef69996
--- /dev/null
@@ -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 (file)
index 0000000..9a15909
--- /dev/null
@@ -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 (file)
index 0000000..33c6edf
--- /dev/null
@@ -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 (file)
index 0000000..106107b
--- /dev/null
@@ -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 (file)
index 0000000..ccc09a3
--- /dev/null
@@ -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<CachedMapInfo> 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 (file)
index 0000000..026b966
--- /dev/null
@@ -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 (file)
index 0000000..faed621
--- /dev/null
@@ -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 (file)
index 0000000..4d87fd5
--- /dev/null
@@ -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 (file)
index 0000000..78c74b1
--- /dev/null
@@ -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 (file)
index 0000000..81edf38
--- /dev/null
@@ -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 (file)
index 0000000..ef75864
--- /dev/null
@@ -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 (file)
index 0000000..cfd96ca
--- /dev/null
@@ -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<Integer> 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 (file)
index 0000000..81ba730
--- /dev/null
@@ -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 (file)
index 0000000..d6ce3a0
--- /dev/null
@@ -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<MapSubSetInfo> 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 (file)
index 0000000..0123f7c
--- /dev/null
@@ -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 (file)
index 0000000..955745f
--- /dev/null
@@ -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 (file)
index 0000000..d81da0c
--- /dev/null
@@ -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 (file)
index 0000000..0b38aa0
--- /dev/null
@@ -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 (file)
index 0000000..412f79e
--- /dev/null
@@ -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 (file)
index 0000000..a4dce40
--- /dev/null
@@ -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<String> areaDefinition;
+
+        CleanStateInfo(CleanMode mode) {
+            this(mode, Optional.empty());
+        }
+
+        CleanStateInfo(CleanMode mode, Optional<String> 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<Node> pointOpt = XPathUtils.getFirstXPathMatchOpt(xml, "//clean/@p");
+                if (pointOpt.isPresent()) {
+                    return new CleanStateInfo(CleanMode.CUSTOM_AREA, pointOpt.map(n -> n.getNodeValue()));
+                }
+                Optional<Node> 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 (file)
index 0000000..9ac6685
--- /dev/null
@@ -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<String> 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<Integer> parseErrorInfo(String xml) throws DataParsingException {
+        for (String attr : ERROR_ATTR_NAMES) {
+            Optional<Node> 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<Integer> value = nodeValueToInt(xml, "value");
+        Optional<Integer> total = nodeValueToInt(xml, "total");
+        Optional<Integer> 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<Integer> 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 (file)
index 0000000..2116b33
--- /dev/null
@@ -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 (file)
index 0000000..9e3bec4
--- /dev/null
@@ -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 (file)
index 0000000..506a4f4
--- /dev/null
@@ -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 (file)
index 0000000..6712062
--- /dev/null
@@ -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<T> {
+    @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 (file)
index 0000000..339c88c
--- /dev/null
@@ -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 (file)
index 0000000..693655e
--- /dev/null
@@ -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 (file)
index 0000000..44f622d
--- /dev/null
@@ -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 (file)
index 0000000..cd0f431
--- /dev/null
@@ -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 (file)
index 0000000..93d5daf
--- /dev/null
@@ -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<LogRecord> records;
+
+    @SerializedName("ret")
+    final String result;
+
+    PortalCleanLogsResponse(String result, List<LogRecord> 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 (file)
index 0000000..ac49dc2
--- /dev/null
@@ -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<Device> devices;
+
+    public PortalDeviceResponse(String result, List<Device> devices) {
+        super(result);
+        this.devices = devices;
+    }
+
+    public List<Device> 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 (file)
index 0000000..29627a8
--- /dev/null
@@ -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> T getResponsePayloadAs(Gson gson, Class<T> 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 (file)
index 0000000..6fff6dc
--- /dev/null
@@ -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 (file)
index 0000000..6d45fda
--- /dev/null
@@ -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<IotProduct> products;
+
+    // unused field: 'code' (integer)
+
+    public PortalIotProductResponse(List<IotProduct> products) {
+        this.products = products;
+    }
+
+    public List<IotProduct> 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 (file)
index 0000000..26bed8d
--- /dev/null
@@ -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 (file)
index 0000000..5fa694f
--- /dev/null
@@ -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 (file)
index 0000000..c8230ab
--- /dev/null
@@ -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 (file)
index 0000000..dc8df1d
--- /dev/null
@@ -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<String> mapImageUrl;
+    public final CleanMode mode;
+
+    public CleanLogRecord(long timestamp, long duration, int area, Optional<String> 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 (file)
index 0000000..07817f6
--- /dev/null
@@ -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 (file)
index 0000000..56b9b64
--- /dev/null
@@ -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 (file)
index 0000000..d6dfea0
--- /dev/null
@@ -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 (file)
index 0000000..07cdf60
--- /dev/null
@@ -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 (file)
index 0000000..aa6ff69
--- /dev/null
@@ -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 (file)
index 0000000..c68bee5
--- /dev/null
@@ -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 (file)
index 0000000..12992e1
--- /dev/null
@@ -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 (file)
index 0000000..c59faf6
--- /dev/null
@@ -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 (file)
index 0000000..0514c8b
--- /dev/null
@@ -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 (file)
index 0000000..c0e8cd1
--- /dev/null
@@ -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 (file)
index 0000000..5305221
--- /dev/null
@@ -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<Node> 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 (file)
index 0000000..0e65c2d
--- /dev/null
@@ -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 (file)
index 0000000..11fbde9
--- /dev/null
@@ -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 (file)
index 0000000..33a3ce4
--- /dev/null
@@ -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<EcovacsApi> 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<EcovacsDevice> 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 (file)
index 0000000..70c2622
--- /dev/null
@@ -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<EcovacsDeviceDiscoveryService> 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<Class<? extends ThingHandlerService>> 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 (file)
index 0000000..febdb7e
--- /dev/null
@@ -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<String> lastDownloadedCleanMapUrl = Optional.empty();
+    private long lastSuccessfulPollTimestamp;
+    private int lastDefaultCleaningPasses = 1;
+    private String serialNumber = "<unset>";
+
+    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<Class<? extends ThingHandlerService>> 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<SuctionPower> 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<MoppingWaterAmount> 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<String> areaDefinition) {
+        lastCleanMode = newMode;
+        if (newMode.isActive()) {
+            lastActiveCleanMode = newMode;
+        } else if (newMode.isIdle()) {
+            lastActiveCleanMode = null;
+        }
+        updateStateAndCommandChannels();
+        Optional<State> 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<Integer> 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<StateOption> 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 <T extends Enum<T>> List<StateOption> createChannelOptions(EcovacsDevice device, T[] values,
+            StateOptionMapping<T> mapping, @Nullable Predicate<StateOptionEntry<T>> 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<EcovacsDevice> 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<CleanLogRecord> 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<CleanMode> 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<CleanMode> result = CLEAN_MODE_MAPPING.get(cleanMode);
+        return result != null ? result.value : "idle";
+    }
+
+    private @Nullable String determineCleaningModeChannelValue(@Nullable CleanMode activeCleanMode) {
+        StateOptionEntry<CleanMode> 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<State> 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<String> 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:<room1>[;<room2>][;<...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:<x1>;<y1>;<x2>;<y2>[: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 (file)
index 0000000..cbb3006
--- /dev/null
@@ -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<T extends Enum<T>> {
+    public final T enumValue;
+    public final String value;
+    public final Optional<DeviceCapability> 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 (file)
index 0000000..eeb5d70
--- /dev/null
@@ -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<T extends Enum<T>> extends HashMap<T, StateOptionEntry<T>> {
+    private static final long serialVersionUID = -6828690091106259902L;
+
+    public String getMappedValue(T key) {
+        StateOptionEntry<T> entry = get(key);
+        if (entry != null) {
+            return entry.value;
+        }
+        throw new IllegalArgumentException("No mapping for key " + key);
+    }
+
+    public Optional<T> findMappedEnumValue(String value) {
+        return entrySet().stream().filter(entry -> entry.getValue().value.equals(value)).map(entry -> entry.getKey())
+                .findFirst();
+    }
+
+    @SafeVarargs
+    public static <T extends Enum<T>> StateOptionMapping<T> of(StateOptionEntry<T>... entries) {
+        StateOptionMapping<T> map = new StateOptionMapping<>();
+        for (StateOptionEntry<T> 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 (file)
index 0000000..4f3f79b
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="ecovacs" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+       <type>binding</type>
+       <name>Ecovacs Binding</name>
+       <description>This is the binding for Ecovacs Deebot vacuum cleaners.</description>
+       <connection>cloud</connection>
+
+</addon:addon>
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 (file)
index 0000000..4c8227d
--- /dev/null
@@ -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 (file)
index 0000000..9c41dba
--- /dev/null
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="ecovacs"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <!-- Ecovacs API account -->
+       <bridge-type id="ecovacsapi">
+               <label>Ecovacs API Account</label>
+               <description>The API account</description>
+
+               <config-description>
+                       <parameter name="email" type="text">
+                               <label>Email</label>
+                               <context>email</context>
+                               <description>Email address for logging in to Ecovacs server</description>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="password" type="text">
+                               <label>Password</label>
+                               <context>password</context>
+                               <description>Password for logging in to Ecovacs server</description>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="continent" type="text">
+                               <label>Continent</label>
+                               <description>Continent the account was registered on. Choose the one you are located in, or "World" if none matches.</description>
+                               <default>ww</default>
+                               <options>
+                                       <option value="ww">World</option>
+                                       <option value="eu">Europe</option>
+                                       <option value="na">North America</option>
+                                       <option value="as">Asia</option>
+                               </options>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+</thing:thing-descriptions>
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 (file)
index 0000000..3bd5209
--- /dev/null
@@ -0,0 +1,355 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="ecovacs"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="vacuum">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="ecovacsapi"/>
+               </supported-bridge-type-refs>
+
+               <label>Ecovacs Vacuum Cleaner</label>
+               <description>Represents an Ecovacs vacuum cleaner</description>
+
+               <channel-groups>
+                       <channel-group id="actions" typeId="actions"/>
+                       <channel-group id="status" typeId="status"/>
+                       <channel-group id="last-clean" typeId="last-clean"/>
+                       <channel-group id="total-stats" typeId="total-stats"/>
+                       <channel-group id="consumables" typeId="consumables"/>
+                       <channel-group id="settings" typeId="settings"/>
+               </channel-groups>
+
+               <config-description>
+                       <parameter name="serialNumber" type="text" required="true">
+                               <label>Device Serial Number</label>
+                       </parameter>
+                       <parameter name="refresh" type="integer" unit="min" min="1" required="false">
+                               <label>Refresh Interval</label>
+                               <advanced>true</advanced>
+                               <description>Specifies the refresh interval in minutes.</description>
+                               <unitLabel>Minutes</unitLabel>
+                               <default>5</default>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-group-type id="status">
+               <label>Status</label>
+               <channels>
+                       <channel id="state" typeId="state"/>
+                       <channel id="battery" typeId="system.battery-level"/>
+                       <channel id="current-cleaning-mode" typeId="current-cleaning-mode"/>
+                       <channel id="current-cleaning-time" typeId="current-cleaning-time"/>
+                       <channel id="current-cleaned-area" typeId="current-cleaned-area"/>
+                       <channel id="current-cleaning-spot-definition" typeId="current-cleaning-spot-definition"/>
+                       <channel id="water-system-present" typeId="water-system-present"/>
+                       <channel id="wifi-rssi" typeId="wifi-rssi"/>
+                       <channel id="error-code" typeId="error-code"/>
+                       <channel id="error-description" typeId="error-description"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-group-type id="actions">
+               <label>Actions</label>
+               <channels>
+                       <channel id="command" typeId="command"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-group-type id="total-stats">
+               <label>Device Lifetime Statistics</label>
+               <channels>
+                       <channel id="total-cleaning-time" typeId="total-cleaning-time"/>
+                       <channel id="total-cleaned-area" typeId="total-cleaned-area"/>
+                       <channel id="total-clean-runs" typeId="total-clean-runs"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-group-type id="last-clean">
+               <label>Last Clean Run</label>
+               <channels>
+                       <channel id="last-clean-start" typeId="last-clean-start"/>
+                       <channel id="last-clean-duration" typeId="last-clean-duration"/>
+                       <channel id="last-clean-area" typeId="last-clean-area"/>
+                       <channel id="last-clean-mode" typeId="last-clean-mode"/>
+                       <channel id="last-clean-map" typeId="last-clean-map"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-group-type id="consumables">
+               <label>Consumables</label>
+               <channels>
+                       <channel id="main-brush-lifetime" typeId="main-brush-lifetime"/>
+                       <channel id="side-brush-lifetime" typeId="side-brush-lifetime"/>
+                       <channel id="dust-filter-lifetime" typeId="dust-filter-lifetime"/>
+                       <channel id="other-component-lifetime" typeId="other-component-lifetime"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-group-type id="settings">
+               <label>Settings</label>
+               <channels>
+                       <channel id="water-amount" typeId="water-amount"/>
+                       <channel id="suction-power" typeId="suction-power"/>
+                       <channel id="voice-volume" typeId="voice-volume"/>
+                       <channel id="auto-empty" typeId="auto-empty"/>
+                       <channel id="cleaning-passes" typeId="cleaning-passes"/>
+                       <channel id="continuous-cleaning" typeId="continuous-cleaning"/>
+                       <channel id="true-detect-3d" typeId="true-detect-3d"/>
+               </channels>
+       </channel-group-type>
+
+       <channel-type id="state">
+               <item-type>String</item-type>
+               <label>State</label>
+               <description>Current state</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="cleaning">Cleaning</option>
+                               <option value="pause">Paused</option>
+                               <option value="stop">Stopped</option>
+                               <option value="washing">Washing the cleaning pad</option>
+                               <option value="drying">Drying the cleaning pad</option>
+                               <option value="returning">Going to charge station</option>
+                               <option value="charging">Charging</option>
+                               <option value="idle">Idle</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="command">
+               <item-type>String</item-type>
+               <label>Command</label>
+               <description>Command to execute</description>
+               <state>
+                       <options>
+                               <option value="clean">Automatic cleaning</option>
+                               <option value="pause">Pause cleaning</option>
+                               <option value="resume">Resume cleaning</option>
+                               <option value="stop">Stop</option>
+                               <option value="charge">Go to charge station</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="current-cleaning-mode">
+               <item-type>String</item-type>
+               <label>Current Cleaning Mode</label>
+               <description>Mode used in current clean cycle</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="auto">@text/ecovacs.cleaning-mode.auto</option>
+                               <option value="edge">@text/ecovacs.cleaning-mode.edge</option>
+                               <option value="spot">@text/ecovacs.cleaning-mode.spot</option>
+                               <option value="spotArea">@text/ecovacs.cleaning-mode.spotArea</option>
+                               <option value="customArea">@text/ecovacs.cleaning-mode.customArea</option>
+                               <option value="singleRoom">@text/ecovacs.cleaning-mode.singleRoom</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="current-cleaning-time">
+               <item-type>Number:Time</item-type>
+               <label>Current Cleaning Time</label>
+               <description>Cleaning time in current clean cycle</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="current-cleaned-area">
+               <item-type>Number:Area</item-type>
+               <label>Current Cleaned Area</label>
+               <description>Cleaned area in current clean cycle</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="current-cleaning-spot-definition" advanced="true">
+               <item-type>String</item-type>
+               <label>Current Cleaning Spot</label>
+               <description>Custom or spot area used in current clean cycle</description>
+               <state readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="total-cleaning-time">
+               <item-type>Number:Time</item-type>
+               <label>Total Cleaning Time</label>
+               <description>Cleaning time in device life time</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="total-cleaned-area">
+               <item-type>Number:Area</item-type>
+               <label>Total Cleaned Area</label>
+               <description>Cleaned area in device life time</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="total-clean-runs">
+               <item-type>Number</item-type>
+               <label>Total Clean Runs</label>
+               <description>Number of cleaning runs in device life time</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="last-clean-start">
+               <item-type>DateTime</item-type>
+               <label>Last Cleaning Start</label>
+               <description>Start time of last completed cleaning run</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="last-clean-duration">
+               <item-type>Number:Time</item-type>
+               <label>Last Cleaning Duration</label>
+               <description>Duration of last completed cleaning run</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="last-clean-area">
+               <item-type>Number:Area</item-type>
+               <label>Last Cleaned Area</label>
+               <description>Cleaned area in last completed cleaning run</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="last-clean-mode">
+               <item-type>String</item-type>
+               <label>Last Cleaning Mode</label>
+               <description>Operation mode used in last completed cleaning run</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="auto">@text/ecovacs.cleaning-mode.auto</option>
+                               <option value="edge">@text/ecovacs.cleaning-mode.edge</option>
+                               <option value="spot">@text/ecovacs.cleaning-mode.spot</option>
+                               <option value="spotArea">@text/ecovacs.cleaning-mode.spotArea</option>
+                               <option value="customArea">@text/ecovacs.cleaning-mode.customArea</option>
+                               <option value="singleRoom">@text/ecovacs.cleaning-mode.singleRoom</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="last-clean-map">
+               <item-type>Image</item-type>
+               <label>Last Clean Map</label>
+               <description>Cleaning map for last completed cleaning run</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="auto-empty" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Auto Empty</label>
+               <description>Automatically empty dust bin in station</description>
+       </channel-type>
+
+       <channel-type id="cleaning-passes" advanced="true">
+               <item-type>Number</item-type>
+               <label>Cleaning Passes</label>
+               <description>Number of cleaning passes used by default (if not overridden in command)</description>
+               <state min="1" max="2" step="1" pattern="%d"/>
+       </channel-type>
+
+       <channel-type id="continuous-cleaning" advanced="true">
+               <item-type>Switch</item-type>
+               <label>Continuous Cleaning</label>
+               <description>Automatically resume unfinished cleaning after charging</description>
+       </channel-type>
+
+       <channel-type id="suction-power">
+               <item-type>String</item-type>
+               <label>Cleaning Power Level</label>
+               <description>Amount of suction power to be used while cleaning</description>
+               <state>
+                       <options>
+                               <option value="silent">Silent</option>
+                               <option value="normal">Normal</option>
+                               <option value="high">Maximum</option>
+                               <option value="higher">Maximum+</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="true-detect-3d" advanced="true">
+               <item-type>Switch</item-type>
+               <label>True Detect 3D</label>
+               <description>Enable the True Detect 3D object recognition technology</description>
+       </channel-type>
+
+       <channel-type id="water-system-present">
+               <item-type>Switch</item-type>
+               <label>Water System Present</label>
+               <description>Water plate with mop attached to device?</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="water-amount" advanced="true">
+               <item-type>String</item-type>
+               <label>Mopping Water Amount</label>
+               <state>
+                       <options>
+                               <option value="low">Low</option>
+                               <option value="medium">Medium</option>
+                               <option value="high">High</option>
+                               <option value="veryhigh">Very high</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="wifi-rssi" advanced="true">
+               <item-type>Number:Power</item-type>
+               <label>Wi-Fi Signal Strength</label>
+               <description>Received signal strength indicator for Wi-Fi</description>
+               <category>QualityOfService</category>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="main-brush-lifetime">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Main Brush Lifetime</label>
+               <description>Remaining life time of main brush in percent</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="side-brush-lifetime">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Side Brush Lifetime</label>
+               <description>Remaining life time of side brush in percent</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="dust-filter-lifetime">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Dust Filter Lifetime</label>
+               <description>Remaining life time of dust bin filter in percent</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="other-component-lifetime">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Other Component Lifetime</label>
+               <description>Remaining time until device maintenance is required in percent</description>
+               <state pattern="%d %unit%" readOnly="true"></state>
+       </channel-type>
+
+       <channel-type id="voice-volume">
+               <item-type>Dimmer</item-type>
+               <label>Voice Volume</label>
+               <description>Volume level of voice reports</description>
+               <state min="0" max="100" step="10"/>
+       </channel-type>
+
+       <channel-type id="error-code">
+               <item-type>Number</item-type>
+               <label>Last Error Code</label>
+               <description>The numerical value (code) of the last encountered error</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="error-description">
+               <item-type>String</item-type>
+               <label>Last Error Description</label>
+               <description>A text describing the last encountered error</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+</thing:thing-descriptions>
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 (file)
index 0000000..36985c9
--- /dev/null
@@ -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"
+    }
+]
index ec4e0c3193e2dbb25ae06ad074bbf150f8c1763b..07c78bca987f432bcf6da838c80b190b5d05d753 100644 (file)
     <module>org.openhab.binding.echonetlite</module>
     <module>org.openhab.binding.ecobee</module>
     <module>org.openhab.binding.ecotouch</module>
+    <module>org.openhab.binding.ecovacs</module>
     <module>org.openhab.binding.ecowatt</module>
     <module>org.openhab.binding.ekey</module>
     <module>org.openhab.binding.electroluxair</module>