]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] Improve Cover support (#15875)
authorCody Cutrer <cody@cutrer.us>
Mon, 11 Dec 2023 18:11:27 +0000 (11:11 -0700)
committerGitHub <noreply@github.com>
Mon, 11 Dec 2023 18:11:27 +0000 (19:11 +0100)
* [mqtt.homeassistant] improve Cover support

 * Add support for covers that report position
 * Handle when command and state values for OPEN/CLOSE/STOP
   differ (as they do by default)
 * Expose the full cover state, since it can have tell you
   if the cover is moving or not
 * Handle covers that have a position only, but not a state

* add constants to clarify up/down values

* Be sure to parse percents from strings in RollshutterValue

---------

Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/RollershutterValue.java
bundles/org.openhab.binding.mqtt.generic/src/test/java/org/openhab/binding/mqtt/generic/values/ValueTests.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/CoverTests.java

index c0a977ac4bed6aec19676d2c9455192aed6a1471..7799668329f5cdf42cb21df885c98b16cd87f901 100644 (file)
@@ -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");
index 5d9de8bb8476b8d2b175220eb85f5cf87289c474..9ce6525d09d16d876d909182ef4f5f4132d63836 100644 (file)
@@ -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
index 2105bfc3bf0102ea45774089363dd01c185a858f..7a55de2fad33a093aff39866d6d5f5f824fa5738 100644 (file)
@@ -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<C extends AbstractChannelConfiguration>
 
     // 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;
@@ -155,8 +158,9 @@ public abstract class AbstractComponent<C extends AbstractChannelConfiguration>
      */
     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<C extends AbstractChannelConfiguration>
      *         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));
     }
 
     /**
index 8191f16c2ec6f9f597be6f987053dee11bcf03bd..24df36d0f254913d298d9a3bdf9bd9c37f7fcd15 100644 (file)
@@ -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<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
@@ -48,18 +57,97 @@ public class Cover extends AbstractComponent<Cover.ChannelConfiguration> {
         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();
     }
 }
index c0c41ef34a20c18762510e658e1a214661ef864a..d80d2c7894fb545a63d5e495ae4fec1ab7536cb6 100644 (file)
 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<Light.ChannelConfiguration
     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 {
@@ -302,22 +295,6 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
 
     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();
index 502d77dcf5e7bcab81617b3e8835df08deeb4331..ff8bde13ec2f612a414c3fc305b5f4f5180bd064 100644 (file)
@@ -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