2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.mqtt.homeassistant.internal.component;
15 import java.math.BigDecimal;
16 import java.util.ArrayList;
17 import java.util.Arrays;
18 import java.util.Collections;
19 import java.util.List;
20 import java.util.stream.Collectors;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
25 import org.openhab.binding.mqtt.generic.values.OnOffValue;
26 import org.openhab.binding.mqtt.generic.values.PercentageValue;
27 import org.openhab.binding.mqtt.generic.values.TextValue;
28 import org.openhab.binding.mqtt.generic.values.Value;
29 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
30 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
31 import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
32 import org.slf4j.Logger;
33 import org.slf4j.LoggerFactory;
35 import com.google.gson.annotations.SerializedName;
38 * A MQTT vacuum, following the https://www.home-assistant.io/components/vacuum.mqtt/ specification.
40 * @author Stefan Triller - Initial contribution
41 * @author Anton Kharuzhyi - Make it compilant with the Specification
44 public class Vacuum extends AbstractComponent<Vacuum.ChannelConfiguration> {
45 public static final String SCHEMA_LEGACY = "legacy";
46 public static final String SCHEMA_STATE = "state";
48 public static final String TRUE = "true";
49 public static final String FALSE = "false";
50 public static final String OFF = "off";
52 public static final String FEATURE_TURN_ON = "turn_on"; // Begin cleaning
53 public static final String FEATURE_TURN_OFF = "turn_off"; // Turn the Vacuum off
54 public static final String FEATURE_RETURN_HOME = "return_home"; // Return to base/dock
55 public static final String FEATURE_START = "start";
56 public static final String FEATURE_STOP = "stop"; // Stop the Vacuum
57 public static final String FEATURE_CLEAN_SPOT = "clean_spot"; // Initialize a spot cleaning cycle
58 public static final String FEATURE_LOCATE = "locate"; // Locate the vacuum (typically by playing a song)
59 public static final String FEATURE_PAUSE = "pause"; // Pause the vacuum
60 public static final String FEATURE_BATTERY = "battery";
61 public static final String FEATURE_STATUS = "status";
62 public static final String FEATURE_FAN_SPEED = "fan_speed";
63 public static final String FEATURE_SEND_COMMAND = "send_command";
66 public static final String STATE_CLEANING = "cleaning";
67 public static final String STATE_DOCKED = "docked";
68 public static final String STATE_PAUSED = "paused";
69 public static final String STATE_IDLE = "idle";
70 public static final String STATE_RETURNING = "returning";
71 public static final String STATE_ERROR = "error";
73 public static final String COMMAND_CH_ID = "command";
74 public static final String FAN_SPEED_CH_ID = "fanSpeed";
75 public static final String CUSTOM_COMMAND_CH_ID = "customCommand";
76 public static final String BATTERY_LEVEL_CH_ID = "batteryLevel";
77 public static final String CHARGING_CH_ID = "charging";
78 public static final String CLEANING_CH_ID = "cleaning";
79 public static final String DOCKED_CH_ID = "docked";
80 public static final String ERROR_CH_ID = "error";
81 public static final String JSON_ATTRIBUTES_CH_ID = "jsonAttributes";
82 public static final String STATE_CH_ID = "state";
84 public static final List<String> LEGACY_DEFAULT_FEATURES = List.of(FEATURE_TURN_ON, FEATURE_TURN_OFF, FEATURE_STOP,
85 FEATURE_RETURN_HOME, FEATURE_BATTERY, FEATURE_STATUS, FEATURE_CLEAN_SPOT);
86 public static final List<String> LEGACY_SUPPORTED_FEATURES = List.of(FEATURE_TURN_ON, FEATURE_TURN_OFF,
87 FEATURE_PAUSE, FEATURE_STOP, FEATURE_RETURN_HOME, FEATURE_BATTERY, FEATURE_STATUS, FEATURE_LOCATE,
88 FEATURE_CLEAN_SPOT, FEATURE_FAN_SPEED, FEATURE_SEND_COMMAND);
90 public static final List<String> STATE_DEFAULT_FEATURES = List.of(FEATURE_START, FEATURE_STOP, FEATURE_RETURN_HOME,
91 FEATURE_STATUS, FEATURE_BATTERY, FEATURE_CLEAN_SPOT);
92 public static final List<String> STATE_SUPPORTED_FEATURES = List.of(FEATURE_START, FEATURE_STOP, FEATURE_PAUSE,
93 FEATURE_RETURN_HOME, FEATURE_BATTERY, FEATURE_STATUS, FEATURE_LOCATE, FEATURE_CLEAN_SPOT, FEATURE_FAN_SPEED,
94 FEATURE_SEND_COMMAND);
96 private static final Logger LOGGER = LoggerFactory.getLogger(Vacuum.class);
99 * Configuration class for MQTT component
101 static class ChannelConfiguration extends AbstractChannelConfiguration {
102 ChannelConfiguration() {
103 super("MQTT Vacuum");
106 // Legacy and Common MQTT vacuum configuration section.
108 @SerializedName("battery_level_template")
109 protected @Nullable String batteryLevelTemplate;
110 @SerializedName("battery_level_topic")
111 protected @Nullable String batteryLevelTopic;
113 @SerializedName("charging_template")
114 protected @Nullable String chargingTemplate;
115 @SerializedName("charging_topic")
116 protected @Nullable String chargingTopic;
118 @SerializedName("cleaning_template")
119 protected @Nullable String cleaningTemplate;
120 @SerializedName("cleaning_topic")
121 protected @Nullable String cleaningTopic;
123 @SerializedName("command_topic")
124 protected @Nullable String commandTopic;
126 @SerializedName("docked_template")
127 protected @Nullable String dockedTemplate;
128 @SerializedName("docked_topic")
129 protected @Nullable String dockedTopic;
131 @SerializedName("error_template")
132 protected @Nullable String errorTemplate;
133 @SerializedName("error_topic")
134 protected @Nullable String errorTopic;
136 @SerializedName("fan_speed_list")
137 protected @Nullable List<String> fanSpeedList;
138 @SerializedName("fan_speed_template")
139 protected @Nullable String fanSpeedTemplate;
140 @SerializedName("fan_speed_topic")
141 protected @Nullable String fanSpeedTopic;
143 @SerializedName("payload_clean_spot")
144 protected @Nullable String payloadCleanSpot = "clean_spot";
145 @SerializedName("payload_locate")
146 protected @Nullable String payloadLocate = "locate";
147 @SerializedName("payload_return_to_base")
148 protected @Nullable String payloadReturnToBase = "return_to_base";
149 @SerializedName("payload_start_pause")
150 protected @Nullable String payloadStartPause = "start_pause"; // Legacy only
151 @SerializedName("payload_stop")
152 protected @Nullable String payloadStop = "stop";
153 @SerializedName("payload_turn_off")
154 protected @Nullable String payloadTurnOff = "turn_off";
155 @SerializedName("payload_turn_on")
156 protected @Nullable String payloadTurnOn = "turn_on";
158 @SerializedName("schema")
159 protected Schema schema = Schema.LEGACY;
161 @SerializedName("send_command_topic")
162 protected @Nullable String sendCommandTopic;
164 @SerializedName("set_fan_speed_topic")
165 protected @Nullable String setFanSpeedTopic;
167 @SerializedName("supported_features")
168 protected @Nullable List<String> supportedFeatures;
170 // State MQTT vacuum configuration section.
172 // Start/Pause replaced by 2 payloads
173 @SerializedName("payload_pause")
174 protected @Nullable String payloadPause = "pause";
175 @SerializedName("payload_start")
176 protected @Nullable String payloadStart = "start";
178 @SerializedName("state_topic")
179 protected @Nullable String stateTopic;
181 @SerializedName("json_attributes_template")
182 protected @Nullable String jsonAttributesTemplate;
183 @SerializedName("json_attributes_topic")
184 protected @Nullable String jsonAttributesTopic;
188 * Creates component based on generic configuration and component configuration type.
190 * @param componentConfiguration generic componentConfiguration with not parsed JSON config
192 public Vacuum(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) {
193 super(componentConfiguration, ChannelConfiguration.class, newStyleChannels);
194 final ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
196 final var allowedSupportedFeatures = channelConfiguration.schema == Schema.LEGACY ? LEGACY_SUPPORTED_FEATURES
197 : STATE_SUPPORTED_FEATURES;
198 final var supportedFeatures = channelConfiguration.supportedFeatures;
199 final var configSupportedFeatures = supportedFeatures == null
200 ? channelConfiguration.schema == Schema.LEGACY ? LEGACY_DEFAULT_FEATURES : STATE_DEFAULT_FEATURES
202 List<String> deviceSupportedFeatures = Collections.emptyList();
204 if (!configSupportedFeatures.isEmpty()) {
205 deviceSupportedFeatures = allowedSupportedFeatures.stream().filter(configSupportedFeatures::contains)
206 .collect(Collectors.toList());
208 if (deviceSupportedFeatures.size() != configSupportedFeatures.size()) {
209 LOGGER.warn("Vacuum discovery config has unsupported or duplicated features. Supported: {}, provided: {}",
210 Arrays.toString(allowedSupportedFeatures.toArray()),
211 Arrays.toString(configSupportedFeatures.toArray()));
214 final List<String> commands = new ArrayList<>();
215 addPayloadToList(deviceSupportedFeatures, FEATURE_CLEAN_SPOT, channelConfiguration.payloadCleanSpot, commands);
216 addPayloadToList(deviceSupportedFeatures, FEATURE_LOCATE, channelConfiguration.payloadLocate, commands);
217 addPayloadToList(deviceSupportedFeatures, FEATURE_RETURN_HOME, channelConfiguration.payloadReturnToBase,
219 addPayloadToList(deviceSupportedFeatures, FEATURE_STOP, channelConfiguration.payloadStop, commands);
220 addPayloadToList(deviceSupportedFeatures, FEATURE_TURN_OFF, channelConfiguration.payloadTurnOff, commands);
221 addPayloadToList(deviceSupportedFeatures, FEATURE_TURN_ON, channelConfiguration.payloadTurnOn, commands);
223 if (channelConfiguration.schema == Schema.LEGACY) {
224 addPayloadToList(deviceSupportedFeatures, FEATURE_PAUSE, channelConfiguration.payloadStartPause, commands);
226 addPayloadToList(deviceSupportedFeatures, FEATURE_PAUSE, channelConfiguration.payloadPause, commands);
227 addPayloadToList(deviceSupportedFeatures, FEATURE_START, channelConfiguration.payloadStart, commands);
230 buildOptionalChannel(COMMAND_CH_ID, ComponentChannelType.STRING, new TextValue(commands.toArray(new String[0])),
231 updateListener, null, channelConfiguration.commandTopic, null, null);
233 final var fanSpeedList = channelConfiguration.fanSpeedList;
234 if (deviceSupportedFeatures.contains(FEATURE_FAN_SPEED) && fanSpeedList != null && !fanSpeedList.isEmpty()) {
235 if (!fanSpeedList.contains(OFF)) {
236 fanSpeedList.add(OFF); // Off value is used when cleaning if OFF
238 var fanSpeedValue = new TextValue(fanSpeedList.toArray(new String[0]));
239 if (channelConfiguration.schema == Schema.LEGACY) {
240 buildOptionalChannel(FAN_SPEED_CH_ID, ComponentChannelType.STRING, fanSpeedValue, updateListener, null,
241 channelConfiguration.setFanSpeedTopic, channelConfiguration.fanSpeedTemplate,
242 channelConfiguration.fanSpeedTopic);
243 } else if (deviceSupportedFeatures.contains(FEATURE_STATUS)) {
244 buildOptionalChannel(FAN_SPEED_CH_ID, ComponentChannelType.STRING, fanSpeedValue, updateListener, null,
245 channelConfiguration.setFanSpeedTopic, "{{ value_json.fan_speed }}",
246 channelConfiguration.stateTopic);
248 LOGGER.info("Status feature is disabled, unable to get fan speed.");
249 buildOptionalChannel(FAN_SPEED_CH_ID, ComponentChannelType.STRING, fanSpeedValue, updateListener, null,
250 channelConfiguration.setFanSpeedTopic, null, null);
254 if (deviceSupportedFeatures.contains(FEATURE_SEND_COMMAND)) {
255 buildOptionalChannel(CUSTOM_COMMAND_CH_ID, ComponentChannelType.STRING, new TextValue(), updateListener,
256 null, channelConfiguration.sendCommandTopic, null, null);
259 if (channelConfiguration.schema == Schema.LEGACY) {
260 // I assume, that if these topics defined in config, then we don't need to check features
261 buildOptionalChannel(BATTERY_LEVEL_CH_ID, ComponentChannelType.DIMMER,
262 new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.ONE, null, null),
263 updateListener, null, null, channelConfiguration.batteryLevelTemplate,
264 channelConfiguration.batteryLevelTopic);
265 buildOptionalChannel(CHARGING_CH_ID, ComponentChannelType.SWITCH, new OnOffValue(TRUE, FALSE),
266 updateListener, null, null, channelConfiguration.chargingTemplate,
267 channelConfiguration.chargingTopic);
268 buildOptionalChannel(CLEANING_CH_ID, ComponentChannelType.SWITCH, new OnOffValue(TRUE, FALSE),
269 updateListener, null, null, channelConfiguration.cleaningTemplate,
270 channelConfiguration.cleaningTopic);
271 buildOptionalChannel(DOCKED_CH_ID, ComponentChannelType.SWITCH, new OnOffValue(TRUE, FALSE), updateListener,
272 null, null, channelConfiguration.dockedTemplate, channelConfiguration.dockedTopic);
273 buildOptionalChannel(ERROR_CH_ID, ComponentChannelType.STRING, new TextValue(), updateListener, null, null,
274 channelConfiguration.errorTemplate, channelConfiguration.errorTopic);
276 if (deviceSupportedFeatures.contains(FEATURE_STATUS)) {
277 // state key is mandatory
278 buildOptionalChannel(STATE_CH_ID, ComponentChannelType.STRING,
279 new TextValue(new String[] { STATE_CLEANING, STATE_DOCKED, STATE_PAUSED, STATE_IDLE,
280 STATE_RETURNING, STATE_ERROR }),
281 updateListener, null, null, "{{ value_json.state }}", channelConfiguration.stateTopic);
282 if (deviceSupportedFeatures.contains(FEATURE_BATTERY)) {
283 buildOptionalChannel(BATTERY_LEVEL_CH_ID, ComponentChannelType.DIMMER,
284 new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.ONE, null, null),
285 updateListener, null, null, "{{ value_json.battery_level }}",
286 channelConfiguration.stateTopic);
291 buildOptionalChannel(JSON_ATTRIBUTES_CH_ID, ComponentChannelType.STRING, new TextValue(), updateListener, null,
292 null, channelConfiguration.jsonAttributesTemplate, channelConfiguration.jsonAttributesTopic);
297 private ComponentChannel buildOptionalChannel(String channelId, ComponentChannelType channelType, Value valueState,
298 ChannelStateUpdateListener channelStateUpdateListener, @Nullable String commandTemplate,
299 @Nullable String commandTopic, @Nullable String stateTemplate, @Nullable String stateTopic) {
300 if ((commandTopic != null && !commandTopic.isBlank()) || (stateTopic != null && !stateTopic.isBlank())) {
301 return buildChannel(channelId, channelType, valueState, getName(), channelStateUpdateListener)
302 .stateTopic(stateTopic, stateTemplate, channelConfiguration.getValueTemplate())
303 .commandTopic(commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(),
310 private void addPayloadToList(List<String> supportedFeatures, String feature, @Nullable String payload,
312 if (supportedFeatures.contains(feature) && payload != null && !payload.isEmpty()) {
318 @SerializedName("legacy")
320 @SerializedName("state")