From 34c2155775741f71f32416855ad8f33296094ceb Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Tue, 2 Jan 2024 12:02:47 -0700 Subject: [PATCH] [mqtt.homeassistant] Improve support for Lock component (#16052) * [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 Signed-off-by: Ciprian Pascu --- .../mqtt/generic/values/OnOffValue.java | 67 ++++- .../mqtt/generic/values/TextValue.java | 39 ++- .../mqtt/generic/values/ValueTests.java | 10 + .../internal/component/AbstractComponent.java | 2 +- .../internal/component/Lock.java | 102 ++++++- .../internal/component/Switch.java | 9 +- .../internal/component/LockTests.java | 275 +++++++++++++----- 7 files changed, 397 insertions(+), 107 deletions(-) diff --git a/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/OnOffValue.java b/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/OnOffValue.java index 47139df314a..8bd0d272f93 100644 --- a/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/OnOffValue.java +++ b/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/OnOffValue.java @@ -12,7 +12,12 @@ */ 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 onStates; + private final Set 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; + } } diff --git a/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/TextValue.java b/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/TextValue.java index 08e958c63a7..2ed6cc4ea78 100644 --- a/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/TextValue.java +++ b/bundles/org.openhab.binding.mqtt.generic/src/main/java/org/openhab/binding/mqtt/generic/values/TextValue.java @@ -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 states; + private final @Nullable Set 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 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 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 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 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 commands = this.states; - if (states != null) { + final Set commands = this.commands; + if (commands != null) { for (String command : commands) { builder = builder.withCommandOption(new CommandOption(command, command)); } 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 ebb7cce64b1..c673851bf12 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 @@ -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"); 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 589f726b971..ca901bcba95 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 @@ -60,7 +60,7 @@ public abstract class AbstractComponent 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; diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java index 83705c37ec0..eab24ceffc7 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Lock.java @@ -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 { - 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 { 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(); - buildChannel(SWITCH_CHANNEL_ID, - new OnOffValue(channelConfiguration.payloadLock, channelConfiguration.payloadUnlock), getName(), - componentConfiguration.getUpdateListener()) + 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()) - .build(); + .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).commandFilter(command -> { + if (command instanceof OnOffType) { + autoUpdate(command.equals(OnOffType.ON)); + } + return true; + }).build(); + + 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()) + .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); + } } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java index ae1e9cbf70f..83521651f8d 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java @@ -69,13 +69,8 @@ public class Switch extends AbstractComponent { 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()) diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java index 0a7c2c76725..d10b09c87b9 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LockTests.java @@ -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 { + 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 + } + """); + + assertThat(component.channels.size(), is(2)); + assertThat(component.getName(), is("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", "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.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("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 testNoStateTopicIsOptimistic() 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" \ - }\ + { + "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 - assertThat(component.channels.size(), is(1)); - assertThat(component.getName(), is("lock")); + 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); - assertChannel(component, Lock.SWITCH_CHANNEL_ID, "zigbee2mqtt/lock/state", "zigbee2mqtt/lock/set/state", "lock", - OnOffValue.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); - - component.getChannel(Lock.SWITCH_CHANNEL_ID).getState().publishValue(OnOffType.OFF); - assertPublished("zigbee2mqtt/lock/set/state", "UNLOCK_"); - component.getChannel(Lock.SWITCH_CHANNEL_ID).getState().publishValue(OnOffType.ON); - assertPublished("zigbee2mqtt/lock/set/state", "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() { - // @formatter:off - publishMessage(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" \ - }\ + 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 + } """); - // @formatter:on + + 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