Skip to content

Commit

Permalink
[mqtt.homeassistant] Improve support for Lock component (openhab#16052)
Browse files Browse the repository at this point in the history
* [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 <[email protected]>
  • Loading branch information
ccutrer authored Jan 2, 2024
1 parent f58898c commit a53b740
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,13 +35,13 @@
*/
@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());
Expand All @@ -45,10 +50,10 @@ public OnOffValue() {
/**
* 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);
Expand All @@ -57,18 +62,37 @@ public OnOffValue(@Nullable String onValue, @Nullable String offValue) {
/**
* 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
Expand All @@ -77,9 +101,9 @@ public OnOffType parseCommand(Command command) throws IllegalArgumentException {
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);
Expand All @@ -104,4 +128,15 @@ public CommandDescriptionBuilder createCommandDescription() {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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;

Expand All @@ -37,30 +38,60 @@
@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()) {
this.states = s;
} 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)) {
Expand Down Expand Up @@ -91,8 +122,8 @@ public StateDescriptionFragmentBuilder createStateDescription(boolean readOnly)
@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));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ public void onoffUpdate() {
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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,27 @@

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
Expand All @@ -39,30 +46,99 @@ static class ChannelConfiguration extends AbstractChannelConfiguration {

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,8 @@ public Switch(ComponentFactory.ComponentConfiguration componentConfiguration) {
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())
Expand Down
Loading

0 comments on commit a53b740

Please sign in to comment.