diff --git a/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/RollershutterValue.java b/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/RollershutterValue.java index c0a977ac4be..7799668329f 100644 --- a/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/RollershutterValue.java +++ b/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/RollershutterValue.java @@ -36,9 +36,46 @@ import org.openhab.core.types.Command; */ @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. @@ -48,17 +85,13 @@ public class RollershutterValue extends 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."); @@ -68,12 +101,14 @@ public class RollershutterValue extends Value { 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; } } @@ -85,43 +120,70 @@ public class RollershutterValue extends Value { 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"); diff --git a/bundles/org.openhab.binding.mqtt.generic/src/test/java/org/openhab/binding/mqtt/generic/values/ValueTests.java b/bundles/org.openhab.binding.mqtt.generic/src/test/java/org/openhab/binding/mqtt/generic/values/ValueTests.java index 5d9de8bb847..9ce6525d09d 100644 --- a/bundles/org.openhab.binding.mqtt.generic/src/test/java/org/openhab/binding/mqtt/generic/values/ValueTests.java +++ b/bundles/org.openhab.binding.mqtt.generic/src/test/java/org/openhab/binding/mqtt/generic/values/ValueTests.java @@ -227,6 +227,39 @@ public class ValueTests { // 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 diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java index 2105bfc3bf0..7a55de2fad3 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.mqtt.homeassistant.internal.component; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -19,6 +20,7 @@ import java.util.TreeMap; 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; @@ -26,7 +28,6 @@ import org.openhab.binding.mqtt.generic.AvailabilityTracker; 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; @@ -66,6 +67,8 @@ public abstract class AbstractComponent // Channels and configuration protected final Map channels = new TreeMap<>(); + protected final List 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; @@ -155,8 +158,9 @@ public abstract class AbstractComponent */ 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)); } /** @@ -166,7 +170,10 @@ public abstract class AbstractComponent * 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)); } /** diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java index 8191f16c2ec..24df36d0f25 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java @@ -15,20 +15,29 @@ package org.openhab.binding.mqtt.homeassistant.internal.component; 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 { - 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 @@ -48,18 +57,97 @@ public class Cover extends AbstractComponent { 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; - buildChannel(SWITCH_CHANNEL_ID, value, getName(), componentConfiguration.getUpdateListener()) - .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) - .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), - channelConfiguration.getQos()) - .build(); + // 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(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(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java index c0c41ef34a2..d80d2c7894f 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java @@ -13,12 +13,7 @@ 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; @@ -32,7 +27,6 @@ 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.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; @@ -249,7 +243,6 @@ public abstract class Light extends AbstractComponent hiddenChannels = new ArrayList<>(); protected final ChannelStateUpdateListener channelStateUpdateListener; public static Light create(ComponentFactory.ComponentConfiguration builder) throws UnsupportedComponentException { @@ -302,22 +295,6 @@ public abstract class Light extends AbstractComponent 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(); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java index 502d77dcf5e..ff8bde13ec2 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java @@ -20,8 +20,11 @@ import java.util.Set; 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} @@ -34,7 +37,7 @@ public class CoverTests extends AbstractComponentTests { @SuppressWarnings("null") @Test - public void test() throws InterruptedException { + public void testStateOnly() throws InterruptedException { // @formatter:off var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ @@ -63,27 +66,135 @@ public class CoverTests extends AbstractComponentTests { """); // @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