*/
@NonNullByDefault
public class RollershutterValue extends Value {
- private final @Nullable String upString;
- private final @Nullable String downString;
- private final String stopString;
+ // openHAB interprets open rollershutters as 0, and closed as 100
+ private static final String UP_VALUE = "0";
+ private static final String DOWN_VALUE = "100";
+ // other devices may interpret it the opposite, so we need to be able
+ // to invert it
+ private static final String INVERTED_UP_VALUE = DOWN_VALUE;
+ private static final String INVERTED_DOWN_VALUE = UP_VALUE;
+
+ private final @Nullable String upCommandString;
+ private final @Nullable String downCommandString;
+ private final @Nullable String stopCommandString;
+ private final @Nullable String upStateString;
+ private final @Nullable String downStateString;
+ private final boolean inverted;
+ private final boolean transformExtentsToString;
+
+ /**
+ * Creates a new rollershutter value.
+ *
+ * @param upCommandString The UP command string.
+ * @param downCommandString The DOWN command string.
+ * @param stopCommandString The STOP command string.
+ * @param upStateString The UP value string. This will be compared to MQTT messages.
+ * @param downStateString The DOWN value string. This will be compared to MQTT messages.
+ * @param inverted Whether to invert 0-100/100-0
+ * @param transformExtentsToString Whether 0/100 will be sent as UP/DOWN
+ */
+ public RollershutterValue(@Nullable String upCommandString, @Nullable String downCommandString,
+ @Nullable String stopCommandString, @Nullable String upStateString, @Nullable String downStateString,
+ boolean inverted, boolean transformExtentsToString) {
+ super(CoreItemFactory.ROLLERSHUTTER,
+ List.of(UpDownType.class, StopMoveType.class, PercentType.class, StringType.class));
+ this.upCommandString = upCommandString;
+ this.downCommandString = downCommandString;
+ this.stopCommandString = stopCommandString;
+ this.upStateString = upStateString;
+ this.downStateString = downStateString;
+ this.inverted = inverted;
+ this.transformExtentsToString = transformExtentsToString;
+ }
/**
* Creates a new rollershutter value.
* @param stopString The STOP value string. This will be compared to MQTT messages.
*/
public RollershutterValue(@Nullable String upString, @Nullable String downString, @Nullable String stopString) {
- super(CoreItemFactory.ROLLERSHUTTER,
- List.of(UpDownType.class, StopMoveType.class, PercentType.class, StringType.class));
- this.upString = upString;
- this.downString = downString;
- this.stopString = stopString == null ? StopMoveType.STOP.name() : stopString;
+ this(upString, downString, stopString, upString, downString, false, true);
}
- @Override
- public Command parseCommand(Command command) throws IllegalArgumentException {
+ private Command parseType(Command command, @Nullable String upString, @Nullable String downString)
+ throws IllegalArgumentException {
if (command instanceof StopMoveType) {
- if (command == StopMoveType.STOP) {
+ if (command == StopMoveType.STOP && stopCommandString != null) {
return command;
} else {
throw new IllegalArgumentException(command.toString() + " is not a valid command for MQTT.");
if (upString != null) {
return command;
} else {
+ // Do not handle inversion here. See parseCommand below
return PercentType.ZERO;
}
} else {
if (downString != null) {
return command;
} else {
+ // Do not handle inversion here. See parseCommand below
return PercentType.HUNDRED;
}
}
return UpDownType.UP;
} else if (updatedValue.equals(downString)) {
return UpDownType.DOWN;
- } else if (updatedValue.equals(stopString)) {
+ } else if (updatedValue.equals(stopCommandString)) {
return StopMoveType.STOP;
+ } else {
+ return PercentType.valueOf(updatedValue);
}
}
throw new IllegalStateException("Cannot call parseCommand() with " + command.toString());
}
+ @Override
+ public Command parseCommand(Command command) throws IllegalArgumentException {
+ // Do not handle inversion in this code path. parseCommand might be called
+ // multiple times when sending a command TO an MQTT topic. The inversion is
+ // handled _only_ in getMQTTpublishValue
+ return parseType(command, upCommandString, downCommandString);
+ }
+
+ @Override
+ public Command parseMessage(Command command) throws IllegalArgumentException {
+ command = parseType(command, upStateString, downStateString);
+ if (inverted && command instanceof PercentType percentType) {
+ return new PercentType(100 - percentType.intValue());
+ }
+ return command;
+ }
+
@Override
public String getMQTTpublishValue(Command command, @Nullable String pattern) {
- final String upString = this.upString;
- final String downString = this.downString;
- final String stopString = this.stopString;
+ return getMQTTpublishValue(command, transformExtentsToString);
+ }
+
+ public String getMQTTpublishValue(Command command, boolean transformExtentsToString) {
+ final String upCommandString = this.upCommandString;
+ final String downCommandString = this.downCommandString;
+ final String stopCommandString = this.stopCommandString;
if (command == UpDownType.UP) {
- if (upString != null) {
- return upString;
+ if (upCommandString != null) {
+ return upCommandString;
} else {
- return ((UpDownType) command).name();
+ return (inverted ? INVERTED_UP_VALUE : UP_VALUE);
}
} else if (command == UpDownType.DOWN) {
- if (downString != null) {
- return downString;
+ if (downCommandString != null) {
+ return downCommandString;
} else {
- return ((UpDownType) command).name();
+ return (inverted ? INVERTED_DOWN_VALUE : DOWN_VALUE);
}
} else if (command == StopMoveType.STOP) {
- if (stopString != null) {
- return stopString;
+ if (stopCommandString != null) {
+ return stopCommandString;
} else {
return ((StopMoveType) command).name();
}
} else if (command instanceof PercentType percentage) {
- if (command.equals(PercentType.HUNDRED) && downString != null) {
- return downString;
- } else if (command.equals(PercentType.ZERO) && upString != null) {
- return upString;
+ if (transformExtentsToString && command.equals(PercentType.HUNDRED) && downCommandString != null) {
+ return downCommandString;
+ } else if (transformExtentsToString && command.equals(PercentType.ZERO) && upCommandString != null) {
+ return upCommandString;
} else {
- return String.valueOf(percentage.intValue());
+ int value = percentage.intValue();
+ if (inverted) {
+ value = 100 - value;
+ }
+ return String.valueOf(value);
}
} else {
throw new IllegalArgumentException("Invalid command type for Rollershutter item");
// Test formatting 0/100
assertThat(v.getMQTTpublishValue(PercentType.ZERO, null), is("fancyON"));
assertThat(v.getMQTTpublishValue(PercentType.HUNDRED, null), is("fancyOff"));
+
+ // Test parsing from MQTT
+ assertThat(v.parseMessage(new StringType("fancyON")), is(UpDownType.UP));
+ assertThat(v.parseMessage(new StringType("fancyOff")), is(UpDownType.DOWN));
+ }
+
+ @Test
+ public void rollershutterUpdateWithDiscreteCommandAndStateStrings() {
+ RollershutterValue v = new RollershutterValue("OPEN", "CLOSE", "STOP", "open", "closed", false, true);
+ // Test with UP/DOWN/STOP command
+ assertThat(v.parseCommand(UpDownType.UP), is(UpDownType.UP));
+ assertThat(v.getMQTTpublishValue(UpDownType.UP, null), is("OPEN"));
+ assertThat(v.parseCommand(UpDownType.DOWN), is(UpDownType.DOWN));
+ assertThat(v.getMQTTpublishValue(UpDownType.DOWN, null), is("CLOSE"));
+ assertThat(v.parseCommand(StopMoveType.STOP), is(StopMoveType.STOP));
+ assertThat(v.getMQTTpublishValue(StopMoveType.STOP, null), is("STOP"));
+
+ // Test with custom string
+ assertThat(v.parseCommand(new StringType("OPEN")), is(UpDownType.UP));
+ assertThat(v.parseCommand(new StringType("CLOSE")), is(UpDownType.DOWN));
+
+ // Test with exact percent
+ Command command = new PercentType(27);
+ assertThat(v.parseCommand((Command) command), is(command));
+ assertThat(v.getMQTTpublishValue(command, null), is("27"));
+
+ // Test formatting 0/100
+ assertThat(v.getMQTTpublishValue(PercentType.ZERO, null), is("OPEN"));
+ assertThat(v.getMQTTpublishValue(PercentType.HUNDRED, null), is("CLOSE"));
+
+ // Test parsing from MQTT
+ assertThat(v.parseMessage(new StringType("open")), is(UpDownType.UP));
+ assertThat(v.parseMessage(new StringType("closed")), is(UpDownType.DOWN));
}
@Test
*/
package org.openhab.binding.mqtt.homeassistant.internal.component;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
-import org.openhab.binding.mqtt.generic.utils.FutureCollector;
import org.openhab.binding.mqtt.generic.values.Value;
import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
// Channels and configuration
protected final Map<String, ComponentChannel> channels = new TreeMap<>();
+ protected final List<ComponentChannel> hiddenChannels = new ArrayList<>();
+
// The hash code ({@link String#hashCode()}) of the configuration string
// Used to determine if a component has changed.
protected final int configHash;
*/
public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
int timeout) {
- return channels.values().stream().map(cChannel -> cChannel.start(connection, scheduler, timeout))
- .collect(FutureCollector.allOf());
+ return Stream.concat(channels.values().stream(), hiddenChannels.stream())
+ .map(v -> v.start(connection, scheduler, timeout)) //
+ .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
}
/**
* exceptionally on errors.
*/
public CompletableFuture<@Nullable Void> stop() {
- return channels.values().stream().map(ComponentChannel::stop).collect(FutureCollector.allOf());
+ return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
+ .filter(Objects::nonNull) //
+ .map(ComponentChannel::stop) //
+ .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
}
/**
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.values.RollershutterValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
import com.google.gson.annotations.SerializedName;
/**
- * A MQTT Cover component, following the https://www.home-assistant.io/components/cover.mqtt/ specification.
+ * A MQTT Cover component, following the https://www.home-assistant.io/integrations/cover.mqtt specification.
*
- * Only Open/Close/Stop works so far.
+ * Supports reporting state and/or position, and commanding OPEN/CLOSE/STOP
+ *
+ * Does not yet support tilt or covers that don't go from 0-100.
*
* @author David Graeff - Initial contribution
+ * @author Cody Cutrer - Add support for position and discrete state strings
*/
@NonNullByDefault
public class Cover extends AbstractComponent<Cover.ChannelConfiguration> {
- public static final String SWITCH_CHANNEL_ID = "cover"; // Randomly chosen channel "ID"
+ public static final String COVER_CHANNEL_ID = "cover";
+ public static final String STATE_CHANNEL_ID = "state";
/**
* Configuration class for MQTT component
protected String payloadClose = "CLOSE";
@SerializedName("payload_stop")
protected String payloadStop = "STOP";
+ @SerializedName("position_closed")
+ protected int positionClosed = 0;
+ @SerializedName("position_open")
+ protected int positionOpen = 100;
+ @SerializedName("position_template")
+ protected @Nullable String positionTemplate;
+ @SerializedName("position_topic")
+ protected @Nullable String positionTopic;
+ @SerializedName("set_position_template")
+ protected @Nullable String setPositionTemplate;
+ @SerializedName("set_position_topic")
+ protected @Nullable String setPositionTopic;
+ @SerializedName("state_closed")
+ protected String stateClosed = "closed";
+ @SerializedName("state_closing")
+ protected String stateClosing = "closing";
+ @SerializedName("state_open")
+ protected String stateOpen = "open";
+ @SerializedName("state_opening")
+ protected String stateOpening = "opening";
+ @SerializedName("state_stopped")
+ protected String stateStopped = "stopped";
}
+ @Nullable
+ ComponentChannel stateChannel = null;
+
public Cover(ComponentFactory.ComponentConfiguration componentConfiguration) {
super(componentConfiguration, ChannelConfiguration.class);
- RollershutterValue value = new RollershutterValue(channelConfiguration.payloadOpen,
- channelConfiguration.payloadClose, channelConfiguration.payloadStop);
+ String stateTopic = channelConfiguration.stateTopic;
+
+ // State can indicate additional information than just
+ // the current position, so expose it as a separate channel
+ if (stateTopic != null) {
+ TextValue value = new TextValue(new String[] { channelConfiguration.stateClosed,
+ channelConfiguration.stateClosing, channelConfiguration.stateOpen,
+ channelConfiguration.stateOpening, channelConfiguration.stateStopped });
+ buildChannel(STATE_CHANNEL_ID, value, "State", componentConfiguration.getUpdateListener())
+ .stateTopic(stateTopic).isAdvanced(true).build();
+ }
+
+ if (channelConfiguration.commandTopic != null) {
+ hiddenChannels.add(stateChannel = buildChannel(STATE_CHANNEL_ID, new TextValue(), "State",
+ componentConfiguration.getUpdateListener())
+ .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
+ channelConfiguration.getQos())
+ .build(false));
+ } else {
+ // no command topic. we need to make sure we send
+ // integers for open and close
+ channelConfiguration.payloadOpen = String.valueOf(channelConfiguration.positionOpen);
+ channelConfiguration.payloadClose = String.valueOf(channelConfiguration.positionClosed);
+ }
+
+ // We will either have positionTopic or stateTopic.
+ // positionTopic is more useful, but if we only have stateTopic,
+ // still build a Rollershutter channel so that UP/DOWN/STOP
+ // commands can be sent
+ String rollershutterStateTopic = channelConfiguration.positionTopic;
+ String stateTemplate = channelConfiguration.positionTemplate;
+ if (rollershutterStateTopic == null) {
+ rollershutterStateTopic = stateTopic;
+ stateTemplate = channelConfiguration.getValueTemplate();
+ }
+ String rollershutterCommandTopic = channelConfiguration.setPositionTopic;
+ if (rollershutterCommandTopic == null) {
+ rollershutterCommandTopic = channelConfiguration.commandTopic;
+ }
+
+ boolean inverted = channelConfiguration.positionOpen > channelConfiguration.positionClosed;
+ final RollershutterValue value = new RollershutterValue(channelConfiguration.payloadOpen,
+ channelConfiguration.payloadClose, channelConfiguration.payloadStop, channelConfiguration.stateOpen,
+ channelConfiguration.stateClosed, inverted, channelConfiguration.setPositionTopic == null);
- buildChannel(SWITCH_CHANNEL_ID, value, getName(), componentConfiguration.getUpdateListener())
- .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
- .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
- channelConfiguration.getQos())
- .build();
+ buildChannel(COVER_CHANNEL_ID, value, "Cover", componentConfiguration.getUpdateListener())
+ .stateTopic(rollershutterStateTopic, stateTemplate)
+ .commandTopic(rollershutterCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos())
+ .commandFilter(command -> {
+ if (stateChannel == null) {
+ return true;
+ }
+ // If we have a state channel, and this is UP/DOWN/STOP, then
+ // we need to send the command to _that_ channel's topic, not
+ // the position topic.
+ if (command instanceof UpDownType || command instanceof StopMoveType) {
+ command = new StringType(value.getMQTTpublishValue(command, false));
+ stateChannel.getState().publishValue(command);
+ return false;
+ }
+ return true;
+ }).build();
}
}
package org.openhab.binding.mqtt.homeassistant.internal.component;
import java.math.BigDecimal;
-import java.util.ArrayList;
import java.util.List;
-import java.util.Objects;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException;
-import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.types.Command;
protected final @Nullable TextValue effectValue;
protected final ColorValue colorValue = new ColorValue(ColorMode.HSB, null, null, 100);
- protected final List<ComponentChannel> hiddenChannels = new ArrayList<>();
protected final ChannelStateUpdateListener channelStateUpdateListener;
public static Light create(ComponentFactory.ComponentConfiguration builder) throws UnsupportedComponentException {
protected abstract void buildChannels();
- @Override
- public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
- int timeout) {
- return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
- .map(v -> v.start(connection, scheduler, timeout)) //
- .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
- }
-
- @Override
- public CompletableFuture<@Nullable Void> stop() {
- return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
- .filter(Objects::nonNull) //
- .map(ComponentChannel::stop) //
- .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
- }
-
@Override
public void postChannelCommand(ChannelUID channelUID, Command value) {
throw new UnsupportedOperationException();
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mqtt.generic.values.RollershutterValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
/**
* Tests for {@link Cover}
@SuppressWarnings("null")
@Test
- public void test() throws InterruptedException {
+ public void testStateOnly() throws InterruptedException {
// @formatter:off
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
"""
""");
// @formatter:on
- assertThat(component.channels.size(), is(1));
+ assertThat(component.channels.size(), is(2));
assertThat(component.getName(), is("cover"));
- assertChannel(component, Cover.SWITCH_CHANNEL_ID, "zigbee2mqtt/cover/state", "zigbee2mqtt/cover/set/state",
- "cover", RollershutterValue.class);
+ assertChannel(component, Cover.STATE_CHANNEL_ID, "zigbee2mqtt/cover/state", "", "State", TextValue.class);
+ assertChannel(component, Cover.COVER_CHANNEL_ID, "zigbee2mqtt/cover/state", "zigbee2mqtt/cover/set/state",
+ "Cover", RollershutterValue.class);
- publishMessage("zigbee2mqtt/cover/state", "100");
- assertState(component, Cover.SWITCH_CHANNEL_ID, PercentType.HUNDRED);
- publishMessage("zigbee2mqtt/cover/state", "0");
- assertState(component, Cover.SWITCH_CHANNEL_ID, PercentType.ZERO);
+ publishMessage("zigbee2mqtt/cover/state", "closed");
+ assertState(component, Cover.COVER_CHANNEL_ID, UpDownType.DOWN);
+ assertState(component, Cover.STATE_CHANNEL_ID, new StringType("closed"));
+ publishMessage("zigbee2mqtt/cover/state", "open");
+ assertState(component, Cover.STATE_CHANNEL_ID, new StringType("open"));
+ assertState(component, Cover.COVER_CHANNEL_ID, UpDownType.UP);
- component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.UP);
assertPublished("zigbee2mqtt/cover/set/state", "OPEN_");
- component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.DOWN);
assertPublished("zigbee2mqtt/cover/set/state", "CLOSE_");
- component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
assertPublished("zigbee2mqtt/cover/set/state", "STOP_");
- component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
- assertPublished("zigbee2mqtt/cover/set/state", "OPEN_", 2);
- component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
- assertPublished("zigbee2mqtt/cover/set/state", "STOP_", 2);
+ }
+
+ @SuppressWarnings("null")
+ @Test
+ public void testPositionAndState() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ """
+ {
+ "dev_cla":"garage",
+ "pos_t":"esphome/single-car-gdo/cover/door/position/state",
+ "set_pos_t":"esphome/single-car-gdo/cover/door/position/command",
+ "name":"Door",
+ "stat_t":"esphome/single-car-gdo/cover/door/state",
+ "cmd_t":"esphome/single-car-gdo/cover/door/command",
+ "avty_t":"esphome/single-car-gdo/status",
+ "uniq_id":"78e36d645710-cover-d27845ad",
+ "dev":{
+ "ids":"78e36d645710",
+ "name":"Single Car Garage Door Opener",
+ "sw":"esphome v2023.10.4 Nov 7 2023, 16:19:39",
+ "mdl":"esp32dev",
+ "mf":"espressif"}
+ }
+ """);
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(2));
+ assertThat(component.getName(), is("Door"));
+
+ assertChannel(component, Cover.STATE_CHANNEL_ID, "esphome/single-car-gdo/cover/door/state", "", "State",
+ TextValue.class);
+ assertChannel(component, Cover.COVER_CHANNEL_ID, "esphome/single-car-gdo/cover/door/position/state",
+ "esphome/single-car-gdo/cover/door/position/command", "Cover", RollershutterValue.class);
+
+ publishMessage("esphome/single-car-gdo/cover/door/state", "closed");
+ assertState(component, Cover.STATE_CHANNEL_ID, new StringType("closed"));
+ publishMessage("esphome/single-car-gdo/cover/door/state", "open");
+ assertState(component, Cover.STATE_CHANNEL_ID, new StringType("open"));
+ publishMessage("esphome/single-car-gdo/cover/door/state", "opening");
+ assertState(component, Cover.STATE_CHANNEL_ID, new StringType("opening"));
+
+ publishMessage("esphome/single-car-gdo/cover/door/position/state", "100");
+ assertState(component, Cover.COVER_CHANNEL_ID, PercentType.ZERO);
+ publishMessage("esphome/single-car-gdo/cover/door/position/state", "40");
+ assertState(component, Cover.COVER_CHANNEL_ID, new PercentType(60));
+ publishMessage("esphome/single-car-gdo/cover/door/position/state", "0");
+ assertState(component, Cover.COVER_CHANNEL_ID, PercentType.HUNDRED);
+
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "100");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "0");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
+ assertPublished("esphome/single-car-gdo/cover/door/command", "STOP");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.UP);
+ assertPublished("esphome/single-car-gdo/cover/door/command", "OPEN");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.DOWN);
+ assertPublished("esphome/single-car-gdo/cover/door/command", "CLOSE");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(new PercentType(40));
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "60");
+ }
+
+ @SuppressWarnings("null")
+ @Test
+ public void testPositionOnly() throws InterruptedException {
+ // @formatter:off
+ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+ """
+ {
+ "dev_cla":"garage",
+ "pos_t":"esphome/single-car-gdo/cover/door/position/state",
+ "set_pos_t":"esphome/single-car-gdo/cover/door/position/command",
+ "name":"Door",
+ "avty_t":"esphome/single-car-gdo/status",
+ "uniq_id":"78e36d645710-cover-d27845ad",
+ "dev":{
+ "ids":"78e36d645710",
+ "name":"Single Car Garage Door Opener",
+ "sw":"esphome v2023.10.4 Nov 7 2023, 16:19:39",
+ "mdl":"esp32dev",
+ "mf":"espressif"}
+ }
+ """);
+ // @formatter:on
+
+ assertThat(component.channels.size(), is(1));
+ assertThat(component.getName(), is("Door"));
+
+ assertChannel(component, Cover.COVER_CHANNEL_ID, "esphome/single-car-gdo/cover/door/position/state",
+ "esphome/single-car-gdo/cover/door/position/command", "Cover", RollershutterValue.class);
+
+ publishMessage("esphome/single-car-gdo/cover/door/position/state", "100");
+ assertState(component, Cover.COVER_CHANNEL_ID, PercentType.ZERO);
+ publishMessage("esphome/single-car-gdo/cover/door/position/state", "40");
+ assertState(component, Cover.COVER_CHANNEL_ID, new PercentType(60));
+ publishMessage("esphome/single-car-gdo/cover/door/position/state", "0");
+ assertState(component, Cover.COVER_CHANNEL_ID, PercentType.HUNDRED);
+
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "100");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "0");
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.UP);
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "100", 2);
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.DOWN);
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "0", 2);
+ component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(new PercentType(40));
+ assertPublished("esphome/single-car-gdo/cover/door/position/command", "60");
}
@Override