]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] Improve support for Lock component (#16052)
authorCody Cutrer <cody@cutrer.us>
Tue, 2 Jan 2024 19:02:47 +0000 (12:02 -0700)
committerGitHub <noreply@github.com>
Tue, 2 Jan 2024 19:02:47 +0000 (20:02 +0100)
* [mqtt.homeassistant] Improve support for Lock component

 * handle state and command payloads differing (as they do by default)
 * expose full state possibilities and OPEN command by adding
   a TextValue channel
* Recognize intermediate lock states as unlocked on the switch channel

Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/OnOffValue.java
bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/TextValue.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/Lock.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java

index 47139df314a1b138e2f8c55ddee8ce976dcc73c9..8bd0d272f930dac734930755eceae209e1ab1320 100644 (file)
  */
 package org.openhab.binding.mqtt.generic.values;
 
+import static java.util.function.Predicate.not;
+
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
@@ -30,13 +35,13 @@ import org.openhab.core.types.CommandOption;
  */
 @NonNullByDefault
 public class OnOffValue extends Value {
-    private final String onState;
-    private final String offState;
+    private final Set<String> onStates;
+    private final Set<String> offStates;
     private final String onCommand;
     private final String offCommand;
 
     /**
-     * Creates a switch On/Off type, that accepts "ON", "1" for on and "OFF","0" for off.
+     * Creates a switch On/Off type, that accepts "ON" for on and "OFF" for off.
      */
     public OnOffValue() {
         this(OnOffType.ON.name(), OnOffType.OFF.name());
@@ -45,10 +50,10 @@ public class OnOffValue extends Value {
     /**
      * Creates a new SWITCH On/Off value.
      *
-     * values send in messages will be the same as those expected in incomming messages
+     * values send in messages will be the same as those expected in incoming messages
      *
-     * @param onValue The ON value string. This will be compared to MQTT messages.
-     * @param offValue The OFF value string. This will be compared to MQTT messages.
+     * @param onValue The ON value string. This will be compared to MQTT messages. Defaults to "ON".
+     * @param offValue The OFF value string. This will be compared to MQTT messages. Defaults to "OFF".
      */
     public OnOffValue(@Nullable String onValue, @Nullable String offValue) {
         this(onValue, offValue, onValue, offValue);
@@ -57,18 +62,37 @@ public class OnOffValue extends Value {
     /**
      * Creates a new SWITCH On/Off value.
      *
-     * @param onState The ON value string. This will be compared to MQTT messages.
-     * @param offState The OFF value string. This will be compared to MQTT messages.
-     * @param onCommand The ON value string. This will be send in MQTT messages.
-     * @param offCommand The OFF value string. This will be send in MQTT messages.
+     * @param onState The ON value string. This will be compared to MQTT messages. Defaults to onCommand if null, or
+     *            "ON" if both are null.
+     * @param offState The OFF value string. This will be compared to MQTT messages. Defaults to offComamand if null, or
+     *            "OFF" if both are null.
+     * @param onCommand The ON value string. This will be send in MQTT messages. Defaults to onState if null, or "ON" if
+     *            both are null.
+     * @param offCommand The OFF value string. This will be send in MQTT messages. Defaults to offCommand if null, or
+     *            "OFF" if both are null.
      */
     public OnOffValue(@Nullable String onState, @Nullable String offState, @Nullable String onCommand,
             @Nullable String offCommand) {
+        this(new String[] { defaultArgument(onState, onCommand, OnOffType.ON.name()) },
+                new String[] { defaultArgument(offState, offCommand, OnOffType.OFF.name()) },
+                defaultArgument(onCommand, onState, OnOffType.ON.name()),
+                defaultArgument(offCommand, offState, OnOffType.OFF.name()));
+    }
+
+    /**
+     * Creates a new SWITCH On/Off value.
+     *
+     * @param onStates A list of valid ON value strings. This will be compared to MQTT messages.
+     * @param offStates A list of valid OFF value strings. This will be compared to MQTT messages.
+     * @param onCommand The ON value string. This will be send in MQTT messages.
+     * @param offCommand The OFF value string. This will be send in MQTT messages.
+     */
+    public OnOffValue(String[] onStates, String[] offStates, String onCommand, String offCommand) {
         super(CoreItemFactory.SWITCH, List.of(OnOffType.class, StringType.class));
-        this.onState = onState == null ? OnOffType.ON.name() : onState;
-        this.offState = offState == null ? OnOffType.OFF.name() : offState;
-        this.onCommand = onCommand == null ? OnOffType.ON.name() : onCommand;
-        this.offCommand = offCommand == null ? OnOffType.OFF.name() : offCommand;
+        this.onStates = Stream.of(onStates).filter(not(String::isBlank)).collect(Collectors.toSet());
+        this.offStates = Stream.of(offStates).filter(not(String::isBlank)).collect(Collectors.toSet());
+        this.onCommand = onCommand;
+        this.offCommand = offCommand;
     }
 
     @Override
@@ -77,9 +101,9 @@ public class OnOffValue extends Value {
             return onOffCommand;
         } else {
             final String updatedValue = command.toString();
-            if (onState.equals(updatedValue)) {
+            if (onStates.contains(updatedValue)) {
                 return OnOffType.ON;
-            } else if (offState.equals(updatedValue)) {
+            } else if (offStates.contains(updatedValue)) {
                 return OnOffType.OFF;
             } else {
                 return OnOffType.valueOf(updatedValue);
@@ -104,4 +128,15 @@ public class OnOffValue extends Value {
         builder = builder.withCommandOption(new CommandOption(offCommand, offCommand));
         return builder;
     }
+
+    private static String defaultArgument(@Nullable String arg1, @Nullable String arg2, String defaultValue) {
+        String result = arg1;
+        if (result == null) {
+            result = arg2;
+        }
+        if (result == null) {
+            result = defaultValue;
+        }
+        return result;
+    }
 }
index 08e958c63a77372c777f6f213be4717a1a0ded6f..2ed6cc4ea7837d26ae48bae5cf470ce63446e975 100644 (file)
@@ -26,6 +26,7 @@ import org.openhab.core.library.types.StringType;
 import org.openhab.core.types.Command;
 import org.openhab.core.types.CommandDescriptionBuilder;
 import org.openhab.core.types.CommandOption;
+import org.openhab.core.types.State;
 import org.openhab.core.types.StateDescriptionFragmentBuilder;
 import org.openhab.core.types.StateOption;
 
@@ -37,14 +38,17 @@ import org.openhab.core.types.StateOption;
 @NonNullByDefault
 public class TextValue extends Value {
     private final @Nullable Set<String> states;
+    private final @Nullable Set<String> commands;
 
     /**
-     * Create a string value with a limited number of allowed states.
+     * Create a string value with a limited number of allowed states and commands.
      *
      * @param states Allowed states. Empty states are filtered out. If the resulting set is empty, all string values
      *            will be allowed.
+     * @param commands Allowed commands. Empty commands are filtered out. If the resulting set is empty, all string
+     *            values will be allowed.
      */
-    public TextValue(String[] states) {
+    public TextValue(String[] states, String[] commands) {
         super(CoreItemFactory.STRING, List.of(StringType.class));
         Set<String> s = Stream.of(states).filter(not(String::isBlank)).collect(Collectors.toSet());
         if (!s.isEmpty()) {
@@ -52,15 +56,42 @@ public class TextValue extends Value {
         } else {
             this.states = null;
         }
+        Set<String> c = Stream.of(commands).filter(not(String::isBlank)).collect(Collectors.toSet());
+        if (!c.isEmpty()) {
+            this.commands = c;
+        } else {
+            this.commands = null;
+        }
+    }
+
+    /**
+     * Create a string value with a limited number of allowed states.
+     *
+     * @param states Allowed states. Empty states are filtered out. If the resulting set is empty, all string values
+     *            will be allowed. This same array is also used for allowed commands.
+     */
+    public TextValue(String[] states) {
+        this(states, states);
     }
 
     public TextValue() {
         super(CoreItemFactory.STRING, List.of(StringType.class));
         this.states = null;
+        this.commands = null;
     }
 
     @Override
     public StringType parseCommand(Command command) throws IllegalArgumentException {
+        final Set<String> commands = this.commands;
+        String valueStr = command.toString();
+        if (commands != null && !commands.contains(valueStr)) {
+            throw new IllegalArgumentException("Value " + valueStr + " not within range");
+        }
+        return new StringType(valueStr);
+    }
+
+    @Override
+    public State parseMessage(Command command) throws IllegalArgumentException {
         final Set<String> states = this.states;
         String valueStr = command.toString();
         if (states != null && !states.contains(valueStr)) {
@@ -91,8 +122,8 @@ public class TextValue extends Value {
     @Override
     public CommandDescriptionBuilder createCommandDescription() {
         CommandDescriptionBuilder builder = super.createCommandDescription();
-        final Set<String> commands = this.states;
-        if (states != null) {
+        final Set<String> commands = this.commands;
+        if (commands != null) {
             for (String command : commands) {
                 builder = builder.withCommandOption(new CommandOption(command, command));
             }
index ebb7cce64b112e60e8d5770169c0422246a3e33f..c673851bf12403e1a2e4181fd4b8a1d7846bc111 100644 (file)
@@ -139,6 +139,16 @@ public class ValueTests {
         assertThat(v.getMQTTpublishValue(OnOffType.ON, "=%s"), is("=fancyON"));
     }
 
+    @Test
+    public void onoffMultiStates() {
+        OnOffValue v = new OnOffValue(new String[] { "LOCKED" }, new String[] { "UNLOCKED", "JAMMED" }, "LOCK",
+                "UNLOCK");
+
+        assertThat(v.parseCommand(new StringType("LOCKED")), is(OnOffType.ON));
+        assertThat(v.parseCommand(new StringType("UNLOCKED")), is(OnOffType.OFF));
+        assertThat(v.parseCommand(new StringType("JAMMED")), is(OnOffType.OFF));
+    }
+
     @Test
     public void openCloseUpdate() {
         OpenCloseValue v = new OpenCloseValue("fancyON", "fancyOff");
index 589f726b9718e37d7fd14ad51b912390634606bb..ca901bcba959018290c93acd04d155852b69f1d9 100644 (file)
@@ -60,7 +60,7 @@ public abstract class AbstractComponent<C extends AbstractChannelConfiguration>
     private static final String JINJA_PREFIX = "JINJA:";
 
     // Component location fields
-    private final ComponentConfiguration componentConfiguration;
+    protected final ComponentConfiguration componentConfiguration;
     protected final @Nullable ChannelGroupTypeUID channelGroupTypeUID;
     protected final @Nullable ChannelGroupUID channelGroupUID;
     protected final HaID haID;
index 83705c37ec0d6c928ecbb5784818e35fdd54d496..eab24ceffc7d6397bab73884b76ef9942cbb47f9 100644 (file)
@@ -14,20 +14,27 @@ 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.ChannelStateUpdateListener;
 import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
 import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
-import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.type.AutoUpdatePolicy;
 
 import com.google.gson.annotations.SerializedName;
 
 /**
- * A MQTT lock, following the https://www.home-assistant.io/components/lock.mqtt/ specification.
+ * A MQTT lock, following the https://www.home-assistant.io/integrations/lock.mqtt specification.
  *
  * @author David Graeff - Initial contribution
+ * @author Cody Cutrer - Support OPEN, full state, and optimistic mode.
  */
 @NonNullByDefault
 public class Lock extends AbstractComponent<Lock.ChannelConfiguration> {
-    public static final String SWITCH_CHANNEL_ID = "lock"; // Randomly chosen channel "ID"
+    public static final String LOCK_CHANNEL_ID = "lock";
+    public static final String STATE_CHANNEL_ID = "state";
 
     /**
      * Configuration class for MQTT component
@@ -39,30 +46,99 @@ public class Lock extends AbstractComponent<Lock.ChannelConfiguration> {
 
         protected boolean optimistic = false;
 
+        @SerializedName("command_topic")
+        protected @Nullable String commandTopic;
         @SerializedName("state_topic")
         protected String stateTopic = "";
         @SerializedName("payload_lock")
         protected String payloadLock = "LOCK";
         @SerializedName("payload_unlock")
         protected String payloadUnlock = "UNLOCK";
-        @SerializedName("command_topic")
-        protected @Nullable String commandTopic;
+        @SerializedName("payload_open")
+        protected @Nullable String payloadOpen;
+        @SerializedName("state_jammed")
+        protected String stateJammed = "JAMMED";
+        @SerializedName("state_locked")
+        protected String stateLocked = "LOCKED";
+        @SerializedName("state_locking")
+        protected String stateLocking = "LOCKING";
+        @SerializedName("state_unlocked")
+        protected String stateUnlocked = "UNLOCKED";
+        @SerializedName("state_unlocking")
+        protected String stateUnlocking = "UNLOCKING";
     }
 
+    private boolean optimistic = false;
+    private OnOffValue lockValue;
+    private TextValue stateValue;
+
     public Lock(ComponentFactory.ComponentConfiguration componentConfiguration) {
         super(componentConfiguration, ChannelConfiguration.class);
 
-        // We do not support all HomeAssistant quirks
-        if (channelConfiguration.optimistic && !channelConfiguration.stateTopic.isBlank()) {
-            throw new ConfigurationException("Component:Lock does not support forced optimistic mode");
-        }
+        this.optimistic = channelConfiguration.optimistic || channelConfiguration.stateTopic.isBlank();
+
+        lockValue = new OnOffValue(new String[] { channelConfiguration.stateLocked },
+                new String[] { channelConfiguration.stateUnlocked, channelConfiguration.stateLocking,
+                        channelConfiguration.stateUnlocking, channelConfiguration.stateJammed },
+                channelConfiguration.payloadLock, channelConfiguration.payloadUnlock);
+
+        buildChannel(LOCK_CHANNEL_ID, lockValue, "Lock", componentConfiguration.getUpdateListener())
+                .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
+                .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
+                        channelConfiguration.getQos())
+                .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).commandFilter(command -> {
+                    if (command instanceof OnOffType) {
+                        autoUpdate(command.equals(OnOffType.ON));
+                    }
+                    return true;
+                }).build();
 
-        buildChannel(SWITCH_CHANNEL_ID,
-                new OnOffValue(channelConfiguration.payloadLock, channelConfiguration.payloadUnlock), getName(),
-                componentConfiguration.getUpdateListener())
+        String[] commands;
+        if (channelConfiguration.payloadOpen == null) {
+            commands = new String[] { channelConfiguration.payloadLock, channelConfiguration.payloadUnlock, };
+        } else {
+            commands = new String[] { channelConfiguration.payloadLock, channelConfiguration.payloadUnlock,
+                    channelConfiguration.payloadOpen };
+        }
+        stateValue = new TextValue(new String[] { channelConfiguration.stateJammed, channelConfiguration.stateLocked,
+                channelConfiguration.stateLocking, channelConfiguration.stateUnlocked,
+                channelConfiguration.stateUnlocking }, commands);
+        buildChannel(STATE_CHANNEL_ID, stateValue, "State", componentConfiguration.getUpdateListener())
                 .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
                 .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
                         channelConfiguration.getQos())
-                .build();
+                .isAdvanced(true).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).commandFilter(command -> {
+                    if (command instanceof StringType stringCommand) {
+                        if (stringCommand.toString().equals(channelConfiguration.payloadLock)) {
+                            autoUpdate(true);
+                        } else if (stringCommand.toString().equals(channelConfiguration.payloadUnlock)
+                                || stringCommand.toString().equals(channelConfiguration.payloadOpen)) {
+                            autoUpdate(false);
+                        }
+                    }
+                    return true;
+                }).build();
+    }
+
+    private void autoUpdate(boolean locking) {
+        if (!optimistic) {
+            return;
+        }
+
+        final ChannelUID lockChannelUID = buildChannelUID(LOCK_CHANNEL_ID);
+        final ChannelUID stateChannelUID = buildChannelUID(STATE_CHANNEL_ID);
+        final ChannelStateUpdateListener updateListener = componentConfiguration.getUpdateListener();
+
+        if (locking) {
+            stateValue.update(new StringType(channelConfiguration.stateLocked));
+            updateListener.updateChannelState(stateChannelUID, stateValue.getChannelState());
+            lockValue.update(OnOffType.ON);
+            updateListener.updateChannelState(lockChannelUID, OnOffType.ON);
+        } else {
+            stateValue.update(new StringType(channelConfiguration.stateUnlocked));
+            updateListener.updateChannelState(stateChannelUID, stateValue.getChannelState());
+            lockValue.update(OnOffType.OFF);
+            updateListener.updateChannelState(lockChannelUID, OnOffType.OFF);
+        }
     }
 }
index ae1e9cbf70fa4d830d3077b0fbd7183eb0f7447a..83521651f8d7971a23d0259ef8f670471c4476d3 100644 (file)
@@ -69,13 +69,8 @@ public class Switch extends AbstractComponent<Switch.ChannelConfiguration> {
             throw new ConfigurationException("Component:Switch does not support forced optimistic mode");
         }
 
-        String stateOn = channelConfiguration.stateOn != null ? channelConfiguration.stateOn
-                : channelConfiguration.payloadOn;
-        String stateOff = channelConfiguration.stateOff != null ? channelConfiguration.stateOff
-                : channelConfiguration.payloadOff;
-
-        OnOffValue value = new OnOffValue(stateOn, stateOff, channelConfiguration.payloadOn,
-                channelConfiguration.payloadOff);
+        OnOffValue value = new OnOffValue(channelConfiguration.stateOn, channelConfiguration.stateOff,
+                channelConfiguration.payloadOn, channelConfiguration.payloadOff);
 
         buildChannel(SWITCH_CHANNEL_ID, value, "state", componentConfiguration.getUpdateListener())
                 .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
index 0a7c2c76725327244c5979a1605902ff8bf21882..d10b09c87b961176f58364b5f3c796479d5355ac 100644 (file)
@@ -14,13 +14,16 @@ package org.openhab.binding.mqtt.homeassistant.internal.component;
 
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 import java.util.Set;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.junit.jupiter.api.Test;
 import org.openhab.binding.mqtt.generic.values.OnOffValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
 import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
 
 /**
  * Tests for {@link Lock}
@@ -34,83 +37,223 @@ public class LockTests extends AbstractComponentTests {
     @SuppressWarnings("null")
     @Test
     public void test() throws InterruptedException {
-        // @formatter:off
-        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
-                """
-                { \
-                  "availability": [ \
-                    { \
-                      "topic": "zigbee2mqtt/bridge/state" \
-                    } \
-                  ], \
-                  "device": { \
-                    "identifiers": [ \
-                      "zigbee2mqtt_0x0000000000000000" \
-                    ], \
-                    "manufacturer": "Locks inc", \
-                    "model": "Lock", \
-                    "name": "LockBlower", \
-                    "sw_version": "Zigbee2MQTT 1.18.2" \
-                  }, \
-                  "name": "lock", \
-                  "payload_unlock": "UNLOCK_", \
-                  "payload_lock": "LOCK_", \
-                  "state_topic": "zigbee2mqtt/lock/state", \
-                  "command_topic": "zigbee2mqtt/lock/set/state" \
-                }\
+        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
+                {
+                  "availability": [
+                    {
+                      "topic": "zigbee2mqtt/bridge/state"
+                    }
+                  ],
+                  "device": {
+                    "identifiers": [
+                      "zigbee2mqtt_0x0000000000000000"
+                    ],
+                    "manufacturer": "Locks inc",
+                    "model": "Lock",
+                    "name": "LockBlower",
+                    "sw_version": "Zigbee2MQTT 1.18.2"
+                  },
+                  "name": "lock",
+                  "payload_unlock": "UNLOCK_",
+                  "payload_lock": "LOCK_",
+                  "state_unlocked": "UNLOCKED_",
+                  "state_locked": "LOCKED_",
+                  "state_topic": "zigbee2mqtt/lock/state",
+                  "command_topic": "zigbee2mqtt/lock/set/state",
+                  "optimistic": true
+                }
                 """);
-        // @formatter:on
 
-        assertThat(component.channels.size(), is(1));
+        assertThat(component.channels.size(), is(2));
         assertThat(component.getName(), is("lock"));
 
-        assertChannel(component, Lock.SWITCH_CHANNEL_ID, "zigbee2mqtt/lock/state", "zigbee2mqtt/lock/set/state", "lock",
+        assertChannel(component, Lock.LOCK_CHANNEL_ID, "zigbee2mqtt/lock/state", "zigbee2mqtt/lock/set/state", "Lock",
                 OnOffValue.class);
+        assertChannel(component, Lock.STATE_CHANNEL_ID, "zigbee2mqtt/lock/state", "zigbee2mqtt/lock/set/state", "State",
+                TextValue.class);
 
-        publishMessage("zigbee2mqtt/lock/state", "LOCK_");
-        assertState(component, Lock.SWITCH_CHANNEL_ID, OnOffType.ON);
-        publishMessage("zigbee2mqtt/lock/state", "LOCK_");
-        assertState(component, Lock.SWITCH_CHANNEL_ID, OnOffType.ON);
-        publishMessage("zigbee2mqtt/lock/state", "UNLOCK_");
-        assertState(component, Lock.SWITCH_CHANNEL_ID, OnOffType.OFF);
-        publishMessage("zigbee2mqtt/lock/state", "LOCK_");
-        assertState(component, Lock.SWITCH_CHANNEL_ID, OnOffType.ON);
+        publishMessage("zigbee2mqtt/lock/state", "LOCKED_");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+        publishMessage("zigbee2mqtt/lock/state", "UNLOCKED_");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        publishMessage("zigbee2mqtt/lock/state", "JAMMED");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        publishMessage("zigbee2mqtt/lock/state", "GARBAGE");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
 
-        component.getChannel(Lock.SWITCH_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
+        component.getChannel(Lock.LOCK_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
         assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK_");
-        component.getChannel(Lock.SWITCH_CHANNEL_ID).getState().publishValue(OnOffType.ON);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.LOCK_CHANNEL_ID).getState().publishValue(OnOffType.ON);
         assertPublished("zigbee2mqtt/lock/set/state", "LOCK_");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("UNLOCK_"));
+        assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK_", 2);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("LOCK_"));
+        assertPublished("zigbee2mqtt/lock/set/state", "LOCK_", 2);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("LOCK")));
+        assertThrows(IllegalArgumentException.class,
+                () -> component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("OPEN")));
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED_"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
     }
 
+    @SuppressWarnings("null")
     @Test
-    public void forceOptimisticIsNotSupported() {
+    public void testNoStateTopicIsOptimistic() throws InterruptedException {
         // @formatter:off
-        publishMessage(configTopicToMqtt(CONFIG_TOPIC),
+        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
                 """
-                { \
-                  "availability": [ \
-                    { \
-                      "topic": "zigbee2mqtt/bridge/state" \
-                    } \
-                  ], \
-                  "device": { \
-                    "identifiers": [ \
-                      "zigbee2mqtt_0x0000000000000000" \
-                    ], \
-                    "manufacturer": "Locks inc", \
-                    "model": "Lock", \
-                    "name": "LockBlower", \
-                    "sw_version": "Zigbee2MQTT 1.18.2" \
-                  }, \
-                  "name": "lock", \
-                  "payload_unlock": "UNLOCK_", \
-                  "payload_lock": "LOCK_", \
-                  "optimistic": "true", \
-                  "state_topic": "zigbee2mqtt/lock/state", \
-                  "command_topic": "zigbee2mqtt/lock/set/state" \
-                }\
+                {
+                  "availability": [
+                    {
+                      "topic": "zigbee2mqtt/bridge/state"
+                    }
+                  ],
+                  "device": {
+                    "identifiers": [
+                      "zigbee2mqtt_0x0000000000000000"
+                    ],
+                    "manufacturer": "Locks inc",
+                    "model": "Lock",
+                    "name": "LockBlower",
+                    "sw_version": "Zigbee2MQTT 1.18.2"
+                  },
+                  "name": "lock",
+                  "command_topic": "zigbee2mqtt/lock/set/state"
+                }
                 """);
         // @formatter:on
+
+        component.getChannel(Lock.LOCK_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
+        assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.LOCK_CHANNEL_ID).getState().publishValue(OnOffType.ON);
+        assertPublished("zigbee2mqtt/lock/set/state", "LOCK");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("UNLOCK"));
+        assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK", 2);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("LOCK"));
+        assertPublished("zigbee2mqtt/lock/set/state", "LOCK", 2);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+
+        assertThrows(IllegalArgumentException.class,
+                () -> component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("OPEN")));
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testOpennable() throws InterruptedException {
+        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
+                {
+                  "availability": [
+                    {
+                      "topic": "zigbee2mqtt/bridge/state"
+                    }
+                  ],
+                  "device": {
+                    "identifiers": [
+                      "zigbee2mqtt_0x0000000000000000"
+                    ],
+                    "manufacturer": "Locks inc",
+                    "model": "Lock",
+                    "name": "LockBlower",
+                    "sw_version": "Zigbee2MQTT 1.18.2"
+                  },
+                  "name": "lock",
+                  "payload_open": "OPEN",
+                  "state_topic": "zigbee2mqtt/lock/state",
+                  "command_topic": "zigbee2mqtt/lock/set/state",
+                  "optimistic": true
+                }
+                """);
+
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("OPEN"));
+        assertPublished("zigbee2mqtt/lock/set/state", "OPEN");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+    }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testNonOptimistic() throws InterruptedException {
+        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
+                {
+                  "availability": [
+                    {
+                      "topic": "zigbee2mqtt/bridge/state"
+                    }
+                  ],
+                  "device": {
+                    "identifiers": [
+                      "zigbee2mqtt_0x0000000000000000"
+                    ],
+                    "manufacturer": "Locks inc",
+                    "model": "Lock",
+                    "name": "LockBlower",
+                    "sw_version": "Zigbee2MQTT 1.18.2"
+                  },
+                  "name": "lock",
+                  "payload_open": "OPEN",
+                  "state_topic": "zigbee2mqtt/lock/state",
+                  "command_topic": "zigbee2mqtt/lock/set/state"
+                }
+                """);
+
+        publishMessage("zigbee2mqtt/lock/state", "LOCKED");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+        publishMessage("zigbee2mqtt/lock/state", "UNLOCKED");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("UNLOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        publishMessage("zigbee2mqtt/lock/state", "LOCKED");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("LOCKED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.ON);
+        publishMessage("zigbee2mqtt/lock/state", "JAMMED");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        publishMessage("zigbee2mqtt/lock/state", "GARBAGE");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+
+        component.getChannel(Lock.LOCK_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
+        assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.LOCK_CHANNEL_ID).getState().publishValue(OnOffType.ON);
+        assertPublished("zigbee2mqtt/lock/set/state", "LOCK");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("UNLOCK"));
+        assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK", 2);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("LOCK"));
+        assertPublished("zigbee2mqtt/lock/set/state", "LOCK", 2);
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
+
+        component.getChannel(Lock.STATE_CHANNEL_ID).getState().publishValue(new StringType("OPEN"));
+        assertPublished("zigbee2mqtt/lock/set/state", "OPEN");
+        assertState(component, Lock.STATE_CHANNEL_ID, new StringType("JAMMED"));
+        assertState(component, Lock.LOCK_CHANNEL_ID, OnOffType.OFF);
     }
 
     @Override