From e67b8d13d31c1c6ba3c1fea95b0ea7a61ec3e394 Mon Sep 17 00:00:00 2001 From: Stefan Triller Date: Sun, 7 Nov 2021 15:57:41 +0100 Subject: [PATCH] [mqtt.homeassistant] Implement Vacuum discovery for Homeassistant MQTT (#11216) * Implement Vacuum discovery for Homeassistant MQTT Closes #8988 Signed-off-by: Stefan Triller * Addressed review comments Signed-off-by: Stefan Triller * Spotless run again Signed-off-by: Stefan Triller --- .../internal/ComponentChannel.java | 12 +- .../internal/component/ComponentFactory.java | 2 + .../internal/component/Vacuum.java | 264 ++++++++++++++++++ 3 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Vacuum.java diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java index dc993efb80828..6865f95a180bb 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java @@ -129,6 +129,7 @@ public static class Builder { private @Nullable String commandTopic; private boolean retain; private boolean trigger; + private boolean isAdvanced; private @Nullable Integer qos; private @Nullable Predicate commandFilter; @@ -141,6 +142,7 @@ public Builder(AbstractComponent component, String channelID, Value valueStat this.channelID = channelID; this.valueState = valueState; this.label = label; + this.isAdvanced = false; this.channelStateUpdateListener = channelStateUpdateListener; } @@ -194,6 +196,11 @@ public Builder trigger(boolean trigger) { return this; } + public Builder isAdvanced(boolean advanced) { + this.isAdvanced = advanced; + return this; + } + public Builder commandFilter(@Nullable Predicate commandFilter) { this.commandFilter = commandFilter; return this; @@ -221,12 +228,13 @@ public ComponentChannel build(boolean addToComponent) { String localStateTopic = stateTopic; if (localStateTopic == null || localStateTopic.isBlank() || this.trigger) { type = ChannelTypeBuilder.trigger(channelTypeUID, label) - .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL)).build(); + .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL)) + .isAdvanced(isAdvanced).build(); } else { StateDescriptionFragment description = valueState.createStateDescription(commandTopic == null).build(); type = ChannelTypeBuilder.state(channelTypeUID, label, channelState.getItemType()) .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL)) - .withStateDescriptionFragment(description).build(); + .withStateDescriptionFragment(description).isAdvanced(isAdvanced).build(); } Configuration configuration = new Configuration(); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index 5be1b36e3bae1..1771a2d473f4a 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -77,6 +77,8 @@ public static AbstractComponent createComponent(ThingUID thingUID, HaID haID, return new Sensor(componentConfiguration); case "switch": return new Switch(componentConfiguration); + case "vacuum": + return new Vacuum(componentConfiguration); default: throw new UnsupportedComponentException("Component '" + haID + "' is unsupported!"); } 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 new file mode 100644 index 0000000000000..f84abc36cd0a6 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Vacuum.java @@ -0,0 +1,264 @@ +/** + * 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 java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +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.values.TextValue; +import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; + +/** + * A MQTT vacuum, following the https://www.home-assistant.io/components/vacuum.mqtt/ specification. + * + * @author Stefan Triller - Initial contribution + */ +@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"; + + /** + * Configuration class for MQTT component + */ + static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Vacuum"); + } + + protected @Nullable String commandTopic; + protected String stateTopic = ""; + protected @Nullable String sendCommandTopic; // for custom_command + + // [start, pause, stop, return_home, battery, status, locate, clean_spot, fan_speed, send_command] + protected String[] supportedFeatures = new String[] {}; + protected @Nullable String setFanSpeedTopic; + protected String[] fanSpeedList = new String[] {}; + + protected @Nullable String jsonAttributesTopic; + protected @Nullable String jsonAttributesTemplate; + } + + 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"); + } + + if (features.contains("stop")) { + possibleCommands.add("stop"); + } + + if (features.contains("pause")) { + possibleCommands.add("pause"); + } + + if (features.contains("return_home")) { + possibleCommands.add("return_to_base"); + } + + if (features.contains("locate")) { + possibleCommands.add("locate"); + } + + 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 (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(); + } + + // {"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(); + } + + 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(); + } + } +}