From 4a3a9d5873e2e92afbab25671e200e23a3251ad0 Mon Sep 17 00:00:00 2001 From: antroids <36043354+antroids@users.noreply.github.com> Date: Sat, 20 Nov 2021 12:44:09 +0100 Subject: [PATCH] [MQTT.Homeassistant] make the mqtt.vacuum implementation compilant with the specification (#11562) Signed-off-by: Anton Kharuzhy --- .../internal/component/Vacuum.java | 451 ++++++++++-------- .../internal/component/VacuumTests.java | 254 ++++++++++ 2 files changed, 503 insertions(+), 202 deletions(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/VacuumTests.java diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Vacuum.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Vacuum.java index b999380fa5721..29381a560c7a4 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Vacuum.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Vacuum.java @@ -15,14 +15,21 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; 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.mqtt.generic.values.DateTimeValue; -import org.openhab.binding.mqtt.generic.values.NumberValue; +import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.PercentageValue; import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.binding.mqtt.generic.values.Value; +import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.gson.annotations.SerializedName; @@ -30,38 +37,62 @@ * A MQTT vacuum, following the https://www.home-assistant.io/components/vacuum.mqtt/ specification. * * @author Stefan Triller - Initial contribution + * @author Anton Kharuzhyi - Make it compilant with the Specification */ @NonNullByDefault public class Vacuum extends AbstractComponent { - public static final String VACUUM_STATE_CHANNEL_ID = "state"; - public static final String VACUUM_COMMAND_CHANNEL_ID = "command"; - public static final String VACUUM_BATTERY_CHANNEL_ID = "batteryLevel"; - public static final String VACUUM_FAN_SPEED_CHANNEL_ID = "fanSpeed"; - - // sensor stats - public static final String VACUUM_MAIN_BRUSH_CHANNEL_ID = "mainBrushUsage"; - public static final String VACUUM_SIDE_BRUSH_CHANNEL_ID = "sideBrushUsage"; - public static final String VACUUM_FILTER_CHANNEL_ID = "filter"; - public static final String VACUUM_SENSOR_CHANNEL_ID = "sensor"; - public static final String VACUUM_CURRENT_CLEAN_TIME_CHANNEL_ID = "currentCleanTime"; - public static final String VACUUM_CURRENT_CLEAN_AREA_CHANNEL_ID = "currentCleanArea"; - public static final String VACUUM_CLEAN_TIME_CHANNEL_ID = "cleanTime"; - public static final String VACUUM_CLEAN_AREA_CHANNEL_ID = "cleanArea"; - public static final String VACUUM_CLEAN_COUNT_CHANNEL_ID = "cleanCount"; - - public static final String VACUUM_LAST_RUN_START_CHANNEL_ID = "lastRunStart"; - public static final String VACUUM_LAST_RUN_END_CHANNEL_ID = "lastRunEnd"; - public static final String VACUUM_LAST_RUN_DURATION_CHANNEL_ID = "lastRunDuration"; - public static final String VACUUM_LAST_RUN_AREA_CHANNEL_ID = "lastRunArea"; - public static final String VACUUM_LAST_RUN_ERROR_CODE_CHANNEL_ID = "lastRunErrorCode"; - public static final String VACUUM_LAST_RUN_ERROR_DESCRIPTION_CHANNEL_ID = "lastRunErrorDescription"; - public static final String VACUUM_LAST_RUN_FINISHED_FLAG_CHANNEL_ID = "lastRunFinishedFlag"; - - public static final String VACUUM_BIN_IN_TIME_CHANNEL_ID = "binInTime"; - public static final String VACUUM_LAST_BIN_OUT_TIME_CHANNEL_ID = "lastBinOutTime"; - public static final String VACUUM_LAST_BIN_FULL_TIME_CHANNEL_ID = "lastBinFullTime"; - - public static final String VACUUM_CUSMTOM_COMMAND_CHANNEL_ID = "customCommand"; + public static final String SCHEMA_LEGACY = "legacy"; + public static final String SCHEMA_STATE = "state"; + + public static final String TRUE = "true"; + public static final String FALSE = "false"; + public static final String OFF = "off"; + + public static final String FEATURE_TURN_ON = "turn_on"; // Begin cleaning + public static final String FEATURE_TURN_OFF = "turn_off"; // Turn the Vacuum off + public static final String FEATURE_RETURN_HOME = "return_home"; // Return to base/dock + public static final String FEATURE_START = "start"; + public static final String FEATURE_STOP = "stop"; // Stop the Vacuum + public static final String FEATURE_CLEAN_SPOT = "clean_spot"; // Initialize a spot cleaning cycle + public static final String FEATURE_LOCATE = "locate"; // Locate the vacuum (typically by playing a song) + public static final String FEATURE_PAUSE = "pause"; // Pause the vacuum + public static final String FEATURE_BATTERY = "battery"; + public static final String FEATURE_STATUS = "status"; + public static final String FEATURE_FAN_SPEED = "fan_speed"; + public static final String FEATURE_SEND_COMMAND = "send_command"; + + // State Schema only + public static final String STATE_CLEANING = "cleaning"; + public static final String STATE_DOCKED = "docked"; + public static final String STATE_PAUSED = "paused"; + public static final String STATE_IDLE = "idle"; + public static final String STATE_RETURNING = "returning"; + public static final String STATE_ERROR = "error"; + + public static final String COMMAND_CH_ID = "command"; + public static final String FAN_SPEED_CH_ID = "fanSpeed"; + public static final String CUSTOM_COMMAND_CH_ID = "customCommand"; + public static final String BATTERY_LEVEL_CH_ID = "batteryLevel"; + public static final String CHARGING_CH_ID = "charging"; + public static final String CLEANING_CH_ID = "cleaning"; + public static final String DOCKED_CH_ID = "docked"; + public static final String ERROR_CH_ID = "error"; + public static final String JSON_ATTRIBUTES_CH_ID = "jsonAttributes"; + public static final String STATE_CH_ID = "state"; + + public static final List LEGACY_DEFAULT_FEATURES = List.of(FEATURE_TURN_ON, FEATURE_TURN_OFF, FEATURE_STOP, + FEATURE_RETURN_HOME, FEATURE_BATTERY, FEATURE_STATUS, FEATURE_CLEAN_SPOT); + public static final List LEGACY_SUPPORTED_FEATURES = List.of(FEATURE_TURN_ON, FEATURE_TURN_OFF, + FEATURE_PAUSE, FEATURE_STOP, FEATURE_RETURN_HOME, FEATURE_BATTERY, FEATURE_STATUS, FEATURE_LOCATE, + FEATURE_CLEAN_SPOT, FEATURE_FAN_SPEED, FEATURE_SEND_COMMAND); + + public static final List STATE_DEFAULT_FEATURES = List.of(FEATURE_START, FEATURE_STOP, FEATURE_RETURN_HOME, + FEATURE_STATUS, FEATURE_BATTERY, FEATURE_CLEAN_SPOT); + public static final List STATE_SUPPORTED_FEATURES = List.of(FEATURE_START, FEATURE_STOP, FEATURE_PAUSE, + FEATURE_RETURN_HOME, FEATURE_BATTERY, FEATURE_STATUS, FEATURE_LOCATE, FEATURE_CLEAN_SPOT, FEATURE_FAN_SPEED, + FEATURE_SEND_COMMAND); + + private static final Logger LOGGER = LoggerFactory.getLogger(Vacuum.class); /** * Configuration class for MQTT component @@ -71,204 +102,220 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { super("MQTT Vacuum"); } + // Legacy and Common MQTT vacuum configuration section. + + @SerializedName("battery_level_template") + protected @Nullable String batteryLevelTemplate; + @SerializedName("battery_level_topic") + protected @Nullable String batteryLevelTopic; + + @SerializedName("charging_template") + protected @Nullable String chargingTemplate; + @SerializedName("charging_topic") + protected @Nullable String chargingTopic; + + @SerializedName("cleaning_template") + protected @Nullable String cleaningTemplate; + @SerializedName("cleaning_topic") + protected @Nullable String cleaningTopic; + @SerializedName("command_topic") protected @Nullable String commandTopic; - @SerializedName("state_topic") - protected String stateTopic = ""; + + @SerializedName("docked_template") + protected @Nullable String dockedTemplate; + @SerializedName("docked_topic") + protected @Nullable String dockedTopic; + + @SerializedName("enabled_by_default") + protected @Nullable Boolean enabledByDefault = true; + + @SerializedName("error_template") + protected @Nullable String errorTemplate; + @SerializedName("error_topic") + protected @Nullable String errorTopic; + + @SerializedName("fan_speed_list") + protected @Nullable List fanSpeedList; + @SerializedName("fan_speed_template") + protected @Nullable String fanSpeedTemplate; + @SerializedName("fan_speed_topic") + protected @Nullable String fanSpeedTopic; + + @SerializedName("payload_clean_spot") + protected @Nullable String payloadCleanSpot = "clean_spot"; + @SerializedName("payload_locate") + protected @Nullable String payloadLocate = "locate"; + @SerializedName("payload_return_to_base") + protected @Nullable String payloadReturnToBase = "return_to_base"; + @SerializedName("payload_start_pause") + protected @Nullable String payloadStartPause = "start_pause"; // Legacy only + @SerializedName("payload_stop") + protected @Nullable String payloadStop = "stop"; + @SerializedName("payload_turn_off") + protected @Nullable String payloadTurnOff = "turn_off"; + @SerializedName("payload_turn_on") + protected @Nullable String payloadTurnOn = "turn_on"; + + @SerializedName("schema") + protected Schema schema = Schema.LEGACY; + @SerializedName("send_command_topic") - protected @Nullable String sendCommandTopic; // for custom_command + protected @Nullable String sendCommandTopic; - // [start, pause, stop, return_home, battery, status, locate, clean_spot, fan_speed, send_command] - @SerializedName("supported_features") - protected String[] supportedFeatures = new String[] {}; @SerializedName("set_fan_speed_topic") protected @Nullable String setFanSpeedTopic; - @SerializedName("fan_speed_list") - protected String[] fanSpeedList = new String[] {}; - @SerializedName("json_attributes_topic") - protected @Nullable String jsonAttributesTopic; + @SerializedName("supported_features") + protected @Nullable List supportedFeatures; + + // State MQTT vacuum configuration section. + + // Start/Pause replaced by 2 payloads + @SerializedName("payload_pause") + protected @Nullable String payloadPause = "pause"; + @SerializedName("payload_start") + protected @Nullable String payloadStart = "start"; + + @SerializedName("state_topic") + protected @Nullable String stateTopic; + @SerializedName("json_attributes_template") protected @Nullable String jsonAttributesTemplate; + @SerializedName("json_attributes_topic") + protected @Nullable String jsonAttributesTopic; } + /** + * Creates component based on generic configuration and component configuration type. + * + * @param componentConfiguration generic componentConfiguration with not parsed JSON config + */ public Vacuum(ComponentFactory.ComponentConfiguration componentConfiguration) { super(componentConfiguration, ChannelConfiguration.class); - - List features = Arrays.asList(channelConfiguration.supportedFeatures); - - // features = [start, pause, stop, return_home, status, locate, clean_spot, fan_speed, send_command] - ArrayList possibleCommands = new ArrayList(); - if (features.contains("start")) { - possibleCommands.add("start"); + final ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener(); + + final var allowedSupportedFeatures = channelConfiguration.schema == Schema.LEGACY ? LEGACY_SUPPORTED_FEATURES + : STATE_SUPPORTED_FEATURES; + final var configSupportedFeatures = channelConfiguration.supportedFeatures == null + ? channelConfiguration.schema == Schema.LEGACY ? LEGACY_DEFAULT_FEATURES : STATE_DEFAULT_FEATURES + : channelConfiguration.supportedFeatures; + List deviceSupportedFeatures = Collections.emptyList(); + + if (!configSupportedFeatures.isEmpty()) { + deviceSupportedFeatures = allowedSupportedFeatures.stream().filter(configSupportedFeatures::contains) + .collect(Collectors.toList()); } - - if (features.contains("stop")) { - possibleCommands.add("stop"); + if (deviceSupportedFeatures.size() != configSupportedFeatures.size()) { + LOGGER.warn("Vacuum discovery config has unsupported or duplicated features. Supported: {}, provided: {}", + Arrays.toString(allowedSupportedFeatures.toArray()), + Arrays.toString(configSupportedFeatures.toArray())); } - if (features.contains("pause")) { - possibleCommands.add("pause"); + final List commands = new ArrayList<>(); + addPayloadToList(deviceSupportedFeatures, FEATURE_CLEAN_SPOT, channelConfiguration.payloadCleanSpot, commands); + addPayloadToList(deviceSupportedFeatures, FEATURE_LOCATE, channelConfiguration.payloadLocate, commands); + addPayloadToList(deviceSupportedFeatures, FEATURE_RETURN_HOME, channelConfiguration.payloadReturnToBase, + commands); + addPayloadToList(deviceSupportedFeatures, FEATURE_STOP, channelConfiguration.payloadStop, commands); + addPayloadToList(deviceSupportedFeatures, FEATURE_TURN_OFF, channelConfiguration.payloadTurnOff, commands); + addPayloadToList(deviceSupportedFeatures, FEATURE_TURN_ON, channelConfiguration.payloadTurnOn, commands); + + if (channelConfiguration.schema == Schema.LEGACY) { + addPayloadToList(deviceSupportedFeatures, FEATURE_PAUSE, channelConfiguration.payloadStartPause, commands); + } else { + addPayloadToList(deviceSupportedFeatures, FEATURE_PAUSE, channelConfiguration.payloadPause, commands); + addPayloadToList(deviceSupportedFeatures, FEATURE_START, channelConfiguration.payloadStart, commands); } - if (features.contains("return_home")) { - possibleCommands.add("return_to_base"); + buildOptionalChannel(COMMAND_CH_ID, new TextValue(commands.toArray(new String[0])), updateListener, null, + channelConfiguration.commandTopic, null, null); + + final var fanSpeedList = channelConfiguration.fanSpeedList; + if (deviceSupportedFeatures.contains(FEATURE_FAN_SPEED) && fanSpeedList != null && !fanSpeedList.isEmpty()) { + if (!fanSpeedList.contains(OFF)) { + fanSpeedList.add(OFF); // Off value is used when cleaning if OFF + } + var fanSpeedValue = new TextValue(fanSpeedList.toArray(new String[0])); + if (channelConfiguration.schema == Schema.LEGACY) { + buildOptionalChannel(FAN_SPEED_CH_ID, fanSpeedValue, updateListener, null, + channelConfiguration.setFanSpeedTopic, channelConfiguration.fanSpeedTemplate, + channelConfiguration.fanSpeedTopic); + } else if (deviceSupportedFeatures.contains(FEATURE_STATUS)) { + buildOptionalChannel(FAN_SPEED_CH_ID, fanSpeedValue, updateListener, null, + channelConfiguration.setFanSpeedTopic, "{{ value_json.fan_speed }}", + channelConfiguration.stateTopic); + } else { + LOGGER.info("Status feature is disabled, unable to get fan speed."); + buildOptionalChannel(FAN_SPEED_CH_ID, fanSpeedValue, updateListener, null, + channelConfiguration.setFanSpeedTopic, null, null); + } } - if (features.contains("locate")) { - possibleCommands.add("locate"); + if (deviceSupportedFeatures.contains(FEATURE_SEND_COMMAND)) { + buildOptionalChannel(CUSTOM_COMMAND_CH_ID, new TextValue(), updateListener, null, + channelConfiguration.sendCommandTopic, null, null); } - TextValue value = new TextValue(possibleCommands.toArray(new String[0])); - buildChannel(VACUUM_COMMAND_CHANNEL_ID, value, "Command", componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.commandTopic).commandTopic(channelConfiguration.commandTopic, false, 1) - .build(); - - List vacuumStates = List.of("docked", "cleaning", "returning", "paused", "idle", "error"); - TextValue valueState = new TextValue(vacuumStates.toArray(new String[0])); - buildChannel(VACUUM_STATE_CHANNEL_ID, valueState, "State", componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.stateTopic, "{{value_json.state}}").build(); - - if (features.contains("battery")) { - // build battery level channel (0-100) - NumberValue batValue = new NumberValue(BigDecimal.ZERO, new BigDecimal(100), new BigDecimal(1), "%"); - buildChannel(VACUUM_BATTERY_CHANNEL_ID, batValue, "Battery Level", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.stateTopic, "{{value_json.battery_level}}").build(); + if (channelConfiguration.schema == Schema.LEGACY) { + // I assume, that if these topics defined in config, then we don't need to check features + buildOptionalChannel(BATTERY_LEVEL_CH_ID, + new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.ONE, null, null), + updateListener, null, null, channelConfiguration.batteryLevelTemplate, + channelConfiguration.batteryLevelTopic); + buildOptionalChannel(CHARGING_CH_ID, new OnOffValue(TRUE, FALSE), updateListener, null, null, + channelConfiguration.chargingTemplate, channelConfiguration.chargingTopic); + buildOptionalChannel(CLEANING_CH_ID, new OnOffValue(TRUE, FALSE), updateListener, null, null, + channelConfiguration.cleaningTemplate, channelConfiguration.cleaningTopic); + buildOptionalChannel(DOCKED_CH_ID, new OnOffValue(TRUE, FALSE), updateListener, null, null, + channelConfiguration.dockedTemplate, channelConfiguration.dockedTopic); + buildOptionalChannel(ERROR_CH_ID, new TextValue(), updateListener, null, null, + channelConfiguration.errorTemplate, channelConfiguration.errorTopic); + } else { + if (deviceSupportedFeatures.contains(FEATURE_STATUS)) { + // state key is mandatory + buildOptionalChannel(STATE_CH_ID, + new TextValue(new String[] { STATE_CLEANING, STATE_DOCKED, STATE_PAUSED, STATE_IDLE, + STATE_RETURNING, STATE_ERROR }), + updateListener, null, null, "{{ value_json.state }}", channelConfiguration.stateTopic); + if (deviceSupportedFeatures.contains(FEATURE_BATTERY)) { + buildOptionalChannel(BATTERY_LEVEL_CH_ID, + new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.ONE, null, null), + updateListener, null, null, "{{ value_json.battery_level }}", + channelConfiguration.stateTopic); + } + } } - if (features.contains("fan_speed")) { - // build fan speed channel with values from channelConfiguration.fan_speed_list - TextValue fanValue = new TextValue(channelConfiguration.fanSpeedList); - buildChannel(VACUUM_FAN_SPEED_CHANNEL_ID, fanValue, "Fan speed", componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.stateTopic, "{{value_json.fan_speed}}") - .commandTopic(channelConfiguration.setFanSpeedTopic, false, 1).build(); - } + buildOptionalChannel(JSON_ATTRIBUTES_CH_ID, new TextValue(), updateListener, null, null, + channelConfiguration.jsonAttributesTemplate, channelConfiguration.jsonAttributesTopic); + } - // {"mainBrush":"220.6","sideBrush":"120.6","filter":"70.6","sensor":"0.0","currentCleanTime":"0.0","currentCleanArea":"0.0","cleanTime":"79.3","cleanArea":"4439.9","cleanCount":183,"last_run_stats":{"startTime":1613503117000,"endTime":1613503136000,"duration":0,"area":"0.0","errorCode":0,"errorDescription":"No - // error","finishedFlag":false},"bin_in_time":1000,"last_bin_out":-1,"last_bin_full":-1,"last_loaded_map":null,"state":"docked","valetudo_state":{"id":8,"name":"Charging"}} - if (features.contains("status")) { - NumberValue currentCleanTimeValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_CURRENT_CLEAN_TIME_CHANNEL_ID, currentCleanTimeValue, "Current Cleaning Time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.currentCleanTime}}") - .build(); - - NumberValue currentCleanAreaValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_CURRENT_CLEAN_AREA_CHANNEL_ID, currentCleanAreaValue, "Current Cleaning Area", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.currentCleanArea}}") - .build(); - - NumberValue cleanTimeValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_CLEAN_TIME_CHANNEL_ID, cleanTimeValue, "Cleaning Time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.cleanTime}}").build(); - - NumberValue cleanAreaValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_CLEAN_AREA_CHANNEL_ID, cleanAreaValue, "Cleaned Area", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.cleanArea}}").build(); - - NumberValue cleaCountValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_CLEAN_COUNT_CHANNEL_ID, cleaCountValue, "Cleaning Counter", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.cleanCount}}").build(); - - DateTimeValue lastStartTime = new DateTimeValue(); - buildChannel(VACUUM_LAST_RUN_START_CHANNEL_ID, lastStartTime, "Last run start time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, - "{{value_json.last_run_stats.startTime}}") - .build(); - - DateTimeValue lastEndTime = new DateTimeValue(); - buildChannel(VACUUM_LAST_RUN_END_CHANNEL_ID, lastEndTime, "Last run end time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, - "{{value_json.last_run_stats.endTime}}") - .build(); - - NumberValue lastRunDurationValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_LAST_RUN_DURATION_CHANNEL_ID, lastRunDurationValue, "Last run duration", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, - "{{value_json.last_run_stats.duration}}") - .build(); - - NumberValue lastRunAreaValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_LAST_RUN_AREA_CHANNEL_ID, lastRunAreaValue, "Last run area", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.last_run_stats.area}}") - .build(); - - NumberValue lastRunErrorCodeValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_LAST_RUN_ERROR_CODE_CHANNEL_ID, lastRunErrorCodeValue, "Last run error code", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, - "{{value_json.last_run_stats.errorCode}}") - .build(); - - TextValue lastRunErrorDescriptionValue = new TextValue(); - buildChannel(VACUUM_LAST_RUN_ERROR_DESCRIPTION_CHANNEL_ID, lastRunErrorDescriptionValue, - "Last run error description", componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, - "{{value_json.last_run_stats.errorDescription}}") - .build(); - - // true/false doesnt map to ON/OFF => use TextValue instead of OnOffValue - TextValue lastRunFinishedFlagValue = new TextValue(); - buildChannel(VACUUM_LAST_RUN_FINISHED_FLAG_CHANNEL_ID, lastRunFinishedFlagValue, "Last run finished flag", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, - "{{value_json.last_run_stats.finishedFlag}}") - .build(); - - // only for valetudo re => advanced channels - DateTimeValue binInValue = new DateTimeValue(); - buildChannel(VACUUM_BIN_IN_TIME_CHANNEL_ID, binInValue, "Bin In Time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.bin_in_time}}") - .isAdvanced(true).build(); - - DateTimeValue lastBinOutValue = new DateTimeValue(); - buildChannel(VACUUM_LAST_BIN_OUT_TIME_CHANNEL_ID, lastBinOutValue, "Last Bin Out Time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.last_bin_out}}") - .isAdvanced(true).build(); - - DateTimeValue lastBinFullValue = new DateTimeValue(); - buildChannel(VACUUM_LAST_BIN_FULL_TIME_CHANNEL_ID, lastBinFullValue, "Last Bin Full Time", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.last_bin_full}}") - .isAdvanced(true).build(); + @Nullable + private ComponentChannel buildOptionalChannel(String channelId, Value valueState, + ChannelStateUpdateListener channelStateUpdateListener, @Nullable String commandTemplate, + @Nullable String commandTopic, @Nullable String stateTemplate, @Nullable String stateTopic) { + if ((commandTopic != null && !commandTopic.isBlank()) || (stateTopic != null && !stateTopic.isBlank())) { + return buildChannel(channelId, valueState, channelConfiguration.getName(), channelStateUpdateListener) + .stateTopic(stateTopic, stateTemplate, channelConfiguration.getValueTemplate()) + .commandTopic(commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), + commandTemplate) + .build(); } + return null; + } - NumberValue mainBrush = new NumberValue(null, null, null, null); - buildChannel(VACUUM_MAIN_BRUSH_CHANNEL_ID, mainBrush, "Main brush usage", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.mainBrush}}").build(); - - NumberValue sideBrush = new NumberValue(null, null, null, null); - buildChannel(VACUUM_SIDE_BRUSH_CHANNEL_ID, sideBrush, "Side brush usage", - componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.sideBrush}}").build(); - - NumberValue filterValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_FILTER_CHANNEL_ID, filterValue, "Filter time", componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.filter}}").build(); - - NumberValue sensorValue = new NumberValue(null, null, null, null); - buildChannel(VACUUM_SENSOR_CHANNEL_ID, sensorValue, "Sensor", componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.jsonAttributesTopic, "{{value_json.sensor}}").build(); - - // if we have a custom command channel for zone cleanup, etc => create text channel - if (channelConfiguration.sendCommandTopic != null) { - TextValue customCommandValue = new TextValue(); - buildChannel(VACUUM_CUSMTOM_COMMAND_CHANNEL_ID, customCommandValue, "Custom Command", - componentConfiguration.getUpdateListener()) - .commandTopic(channelConfiguration.sendCommandTopic, false, 1) - .stateTopic(channelConfiguration.sendCommandTopic).build(); + private void addPayloadToList(List supportedFeatures, String feature, @Nullable String payload, + List list) { + if (supportedFeatures.contains(feature) && payload != null && !payload.isEmpty()) { + list.add(payload); } } + + public enum Schema { + @SerializedName("legacy") + LEGACY, + @SerializedName("state") + STATE + } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/VacuumTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/VacuumTests.java new file mode 100644 index 0000000000000..ec29ee6d8303c --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/VacuumTests.java @@ -0,0 +1,254 @@ +/** + * Copyright (c) 2010-2021 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.mqtt.homeassistant.internal.component; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.PercentageValue; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.UnDefType; + +/** + * Tests for {@link Vacuum} + * + * @author Anton Kharuzhy - Initial contribution + */ +@SuppressWarnings("ConstantConditions") +public class VacuumTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "vacuum/rockrobo_vacuum"; + + @Test + public void testRoborockValetudo() { + // @formatter:off + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), "{" + + "\"name\":\"Rockrobo\"," + + "\"unique_id\":\"rockrobo_vacuum\"," + + "\"schema\":\"state\"," + + "\"device\":{" + + " \"manufacturer\":\"Roborock\"," + + " \"model\":\"v1\"," + + " \"name\":\"rockrobo\"," + + " \"identifiers\":[\"rockrobo\"]," + + " \"sw_version\":\"0.9.9\"" + + "}," + + "\"supported_features\":[\"start\",\"pause\",\"stop\",\"return_home\",\"battery\",\"status\"," + + " \"locate\",\"clean_spot\",\"fan_speed\",\"send_command\"]," + + "\"command_topic\":\"valetudo/rockrobo/command\"," + + "\"state_topic\":\"valetudo/rockrobo/state\"," + + "\"set_fan_speed_topic\":\"valetudo/rockrobo/set_fan_speed\"," + + "\"fan_speed_list\":[\"min\",\"medium\",\"high\",\"max\",\"mop\"]," + + "\"send_command_topic\":\"valetudo/rockrobo/custom_command\"," + + "\"json_attributes_topic\":\"valetudo/rockrobo/attributes\"" + + "}"); + // @formatter:on + + assertThat(component.channels.size(), is(6)); // command, state, fan speed, send command, battery, json attrs + assertThat(component.getName(), is("Rockrobo")); + assertChannel(component, Vacuum.COMMAND_CH_ID, "", "valetudo/rockrobo/command", "Rockrobo", TextValue.class); + assertChannel(component, Vacuum.STATE_CH_ID, "valetudo/rockrobo/state", "", "Rockrobo", TextValue.class); + assertChannel(component, Vacuum.FAN_SPEED_CH_ID, "valetudo/rockrobo/state", "valetudo/rockrobo/set_fan_speed", + "Rockrobo", TextValue.class); + assertChannel(component, Vacuum.CUSTOM_COMMAND_CH_ID, "", "valetudo/rockrobo/custom_command", "Rockrobo", + TextValue.class); + assertChannel(component, Vacuum.BATTERY_LEVEL_CH_ID, "valetudo/rockrobo/state", "", "Rockrobo", + PercentageValue.class); + assertChannel(component, Vacuum.JSON_ATTRIBUTES_CH_ID, "valetudo/rockrobo/attributes", "", "Rockrobo", + TextValue.class); + + assertState(component, Vacuum.STATE_CH_ID, UnDefType.UNDEF); + assertState(component, Vacuum.FAN_SPEED_CH_ID, UnDefType.UNDEF); + assertState(component, Vacuum.BATTERY_LEVEL_CH_ID, UnDefType.UNDEF); + assertState(component, Vacuum.JSON_ATTRIBUTES_CH_ID, UnDefType.UNDEF); + + // @formatter:off + String jsonValue; + publishMessage("valetudo/rockrobo/attributes", jsonValue = "{" + + "\"mainBrush\":\"245.1\"," + + "\"sideBrush\":\"145.1\"," + + "\"filter\":\"95.1\"," + + "\"sensor\":\"0.0\"," + + "\"currentCleanTime\":\"52.0\"," + + "\"currentCleanArea\":\"46.7\"," + + "\"cleanTime\":\"54.9\"," + + "\"cleanArea\":\"3280.9\"," + + "\"cleanCount\":84," + + "\"last_run_stats\":{" + + " \"startTime\":1633257319000," + + " \"endTime\":1633260439000," + + " \"duration\":3120," + + " \"area\":\"46.7\"," + + " \"errorCode\":0," + + " \"errorDescription\":\"No error\"," + + " \"finishedFlag\":true" + + "}," + + "\"last_bin_out\":2147483647000," + + "\"state\":\"docked\"," + + "\"valetudo_state\":{" + + " \"id\":8," + + " \"name\":\"Charging\"" + + "}," + + "\"last_bin_full\":0" + + "}"); + // @formatter:on + + // @formatter:off + publishMessage("valetudo/rockrobo/state", "{" + + "\"state\":\"docked\"," + + "\"battery_level\":100," + + "\"fan_speed\":\"max\"" + + "}"); + // @formatter:on + + assertState(component, Vacuum.STATE_CH_ID, new StringType(Vacuum.STATE_DOCKED)); + assertState(component, Vacuum.FAN_SPEED_CH_ID, new StringType("max")); + assertState(component, Vacuum.BATTERY_LEVEL_CH_ID, new PercentType(100)); + assertState(component, Vacuum.JSON_ATTRIBUTES_CH_ID, new StringType(jsonValue)); + + component.getChannel(Vacuum.COMMAND_CH_ID).getState().publishValue(new StringType("start")); + assertPublished("valetudo/rockrobo/command", "start"); + + // @formatter:off + publishMessage("valetudo/rockrobo/state", "{" + + "\"state\":\"cleaning\"," + + "\"battery_level\":99," + + "\"fan_speed\":\"max\"" + + "}"); + // @formatter:on + + assertState(component, Vacuum.STATE_CH_ID, new StringType(Vacuum.STATE_CLEANING)); + assertState(component, Vacuum.FAN_SPEED_CH_ID, new StringType("max")); + assertState(component, Vacuum.BATTERY_LEVEL_CH_ID, new PercentType(99)); + assertState(component, Vacuum.JSON_ATTRIBUTES_CH_ID, new StringType(jsonValue)); + + component.getChannel(Vacuum.FAN_SPEED_CH_ID).getState().publishValue(new StringType("medium")); + assertPublished("valetudo/rockrobo/set_fan_speed", "medium"); + + // @formatter:off + publishMessage("valetudo/rockrobo/state", "{" + + "\"state\":\"returning\"," + + "\"battery_level\":80," + + "\"fan_speed\":\"medium\"" + + "}"); + // @formatter:on + + assertState(component, Vacuum.STATE_CH_ID, new StringType(Vacuum.STATE_RETURNING)); + assertState(component, Vacuum.FAN_SPEED_CH_ID, new StringType("medium")); + assertState(component, Vacuum.BATTERY_LEVEL_CH_ID, new PercentType(80)); + assertState(component, Vacuum.JSON_ATTRIBUTES_CH_ID, new StringType(jsonValue)); + } + + @Test + public void testLegacySchema() { + // @formatter:off + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), "{" + + "\"name\":\"Rockrobo\"," + + "\"unique_id\":\"rockrobo_vacuum\"," + + "\"device\":{" + + " \"manufacturer\":\"Roborock\"," + + " \"model\":\"v1\"," + + " \"name\":\"rockrobo\"," + + " \"identifiers\":[\"rockrobo\"]," + + " \"sw_version\":\"0.9.9\"" + + "}," + + "\"supported_features\":[\"turn_on\", \"turn_off\",\"pause\",\"stop\",\"return_home\",\"battery\",\"status\"," + + " \"locate\",\"clean_spot\",\"fan_speed\",\"send_command\"]," + + "\"command_topic\":\"vacuum/command\"," + + "\"battery_level_topic\":\"vacuum/state\"," + + "\"battery_level_template\":\"{{ value_json.battery_level }}\"," + + "\"charging_topic\":\"vacuum/state\"," + + "\"charging_template\":\"{{ value_json.charging }}\"," + + "\"cleaning_topic\":\"vacuum/state\"," + + "\"cleaning_template\":\"{{ value_json.cleaning }}\"," + + "\"docked_topic\":\"vacuum/state\"," + + "\"docked_template\":\"{{ value_json.docked }}\"," + + "\"error_topic\":\"vacuum/state\"," + + "\"error_template\":\"{{ value_json.error }}\"," + + "\"fan_speed_topic\":\"vacuum/state\"," + + "\"set_fan_speed_topic\":\"vacuum/set_fan_speed\"," + + "\"fan_speed_template\":\"{{ value_json.fan_speed }}\"," + + "\"fan_speed_list\":[\"min\",\"medium\",\"high\",\"max\"]," + + "\"send_command_topic\":\"vacuum/send_command\"" + + "}"); + // @formatter:on + + assertThat(component.channels.size(), is(8)); // command, battery, charging, cleaning, docked, error, + // fan speed, send command + assertThat(component.getName(), is("Rockrobo")); + assertChannel(component, Vacuum.COMMAND_CH_ID, "", "vacuum/command", "Rockrobo", TextValue.class); + assertChannel(component, Vacuum.BATTERY_LEVEL_CH_ID, "vacuum/state", "", "Rockrobo", PercentageValue.class); + assertChannel(component, Vacuum.CHARGING_CH_ID, "vacuum/state", "", "Rockrobo", OnOffValue.class); + assertChannel(component, Vacuum.CLEANING_CH_ID, "vacuum/state", "", "Rockrobo", OnOffValue.class); + assertChannel(component, Vacuum.DOCKED_CH_ID, "vacuum/state", "", "Rockrobo", OnOffValue.class); + assertChannel(component, Vacuum.ERROR_CH_ID, "vacuum/state", "", "Rockrobo", TextValue.class); + assertChannel(component, Vacuum.FAN_SPEED_CH_ID, "vacuum/state", "vacuum/set_fan_speed", "Rockrobo", + TextValue.class); + assertChannel(component, Vacuum.CUSTOM_COMMAND_CH_ID, "", "vacuum/send_command", "Rockrobo", TextValue.class); + + // @formatter:off + publishMessage("vacuum/state", "{" + + "\"battery_level\": 61," + + "\"docked\": true," + + "\"cleaning\": false," + + "\"charging\": true," + + "\"fan_speed\": \"off\"," + + "\"error\": \"Error message\"" + + "}"); + // @formatter:on + + assertState(component, Vacuum.BATTERY_LEVEL_CH_ID, new PercentType(61)); + assertState(component, Vacuum.DOCKED_CH_ID, OnOffType.ON); + assertState(component, Vacuum.CLEANING_CH_ID, OnOffType.OFF); + assertState(component, Vacuum.CHARGING_CH_ID, OnOffType.ON); + assertState(component, Vacuum.FAN_SPEED_CH_ID, new StringType("off")); + assertState(component, Vacuum.ERROR_CH_ID, new StringType("Error message")); + + component.getChannel(Vacuum.COMMAND_CH_ID).getState().publishValue(new StringType("turn_on")); + assertPublished("vacuum/command", "turn_on"); + + // @formatter:off + publishMessage("vacuum/state", "{" + + "\"battery_level\": 55," + + "\"docked\": false," + + "\"cleaning\": true," + + "\"charging\": false," + + "\"fan_speed\": \"medium\"," + + "\"error\": \"\"" + + "}"); + // @formatter:on + + assertState(component, Vacuum.BATTERY_LEVEL_CH_ID, new PercentType(55)); + assertState(component, Vacuum.DOCKED_CH_ID, OnOffType.OFF); + assertState(component, Vacuum.CLEANING_CH_ID, OnOffType.ON); + assertState(component, Vacuum.CHARGING_CH_ID, OnOffType.OFF); + assertState(component, Vacuum.FAN_SPEED_CH_ID, new StringType("medium")); + assertState(component, Vacuum.ERROR_CH_ID, new StringType("")); + + component.getChannel(Vacuum.FAN_SPEED_CH_ID).getState().publishValue(new StringType("high")); + assertPublished("vacuum/set_fan_speed", "high"); + + component.getChannel(Vacuum.CUSTOM_COMMAND_CH_ID).getState().publishValue(new StringType("custom_command")); + assertPublished("vacuum/send_command", "custom_command"); + } + + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}