[mqtt.homeassistant] Implement Valve (#17622)

Signed-off-by: Cody Cutrer <cody@cutrer.us>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Cody Cutrer 2024-10-25 10:10:11 -05:00 committed by Ciprian Pascu
parent 76315ac83d
commit d74eb297b0
3 changed files with 551 additions and 0 deletions

View File

@ -87,6 +87,8 @@ public class ComponentFactory {
return new Update(componentConfiguration, newStyleChannels);
case "vacuum":
return new Vacuum(componentConfiguration, newStyleChannels);
case "valve":
return new Valve(componentConfiguration, newStyleChannels);
default:
throw new UnsupportedComponentException("Component '" + haID + "' is unsupported!");
}

View File

@ -0,0 +1,328 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mqtt.homeassistant.internal.component;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
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.PercentageValue;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.type.AutoUpdatePolicy;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.JsonNodeType;
import com.google.gson.annotations.SerializedName;
/**
* A MQTT Valve component, following the https://www.home-assistant.io/integrations/valve.mqtt/ specification.
*
* @author Cody Cutrer - Initial contribution
*/
@NonNullByDefault
public class Valve extends AbstractComponent<Valve.ChannelConfiguration> implements ChannelStateUpdateListener {
public static final String VALVE_CHANNEL_ID = "valve";
public static final String STATE_CHANNEL_ID = "state";
public static final String RAW_STATE_CHANNEL_ID = "state";
private static final String POSITION_KEY = "position";
private static final String STATE_KEY = "state";
private final Logger logger = LoggerFactory.getLogger(Valve.class);
/**
* Configuration class for MQTT component
*/
static class ChannelConfiguration extends AbstractChannelConfiguration {
ChannelConfiguration() {
super("MQTT Valve");
}
protected @Nullable Boolean optimistic;
@SerializedName("state_topic")
protected @Nullable String stateTopic;
@SerializedName("command_template")
protected @Nullable String commandTemplate;
@SerializedName("command_topic")
protected String commandTopic = "";
@SerializedName("payload_close")
protected @Nullable String payloadClose = "CLOSE";
@SerializedName("payload_open")
protected @Nullable String payloadOpen = "OPEN";
@SerializedName("payload_stop")
protected @Nullable String payloadStop;
@SerializedName("position_closed")
protected int positionClosed = 0;
@SerializedName("position_open")
protected int positionOpen = 100;
@SerializedName("reports_position")
protected boolean reportsPosition = false;
@SerializedName("state_closed")
protected @Nullable String stateClosed = "closed";
@SerializedName("state_closing")
protected @Nullable String stateClosing = "closing";
@SerializedName("state_open")
protected @Nullable String stateOpen = "open";
@SerializedName("state_opening")
protected @Nullable String stateOpening = "opening";
}
private final OnOffValue onOffValue;
private final PercentageValue positionValue;
private final TextValue stateValue;
private final ChannelStateUpdateListener channelStateUpdateListener;
private final ObjectMapper objectMapper = new ObjectMapper();
public Valve(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) {
super(componentConfiguration, ChannelConfiguration.class, newStyleChannels);
this.channelStateUpdateListener = componentConfiguration.getUpdateListener();
AutoUpdatePolicy autoUpdatePolicy = null;
if ((channelConfiguration.optimistic != null && channelConfiguration.optimistic == true)
|| channelConfiguration.stateTopic == null) {
autoUpdatePolicy = AutoUpdatePolicy.RECOMMEND;
}
onOffValue = new OnOffValue(channelConfiguration.stateOpen, channelConfiguration.stateClosed,
channelConfiguration.payloadOpen, channelConfiguration.payloadClose);
positionValue = new PercentageValue(BigDecimal.valueOf(channelConfiguration.positionClosed),
BigDecimal.valueOf(channelConfiguration.positionOpen), null, null, null);
if (channelConfiguration.reportsPosition) {
buildChannel(VALVE_CHANNEL_ID, ComponentChannelType.DIMMER, positionValue, getName(), this)
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos(), channelConfiguration.commandTemplate)
.withAutoUpdatePolicy(autoUpdatePolicy).build();
} else {
buildChannel(VALVE_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, getName(), this)
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos(), channelConfiguration.commandTemplate)
.withAutoUpdatePolicy(autoUpdatePolicy).build();
}
List<String> commandValues = new ArrayList<>();
addCommandValue(commandValues, channelConfiguration.payloadOpen);
addCommandValue(commandValues, channelConfiguration.payloadClose);
addCommandValue(commandValues, channelConfiguration.payloadStop);
List<String> stateValues = new ArrayList<>();
addCommandValue(stateValues, channelConfiguration.stateOpen);
addCommandValue(stateValues, channelConfiguration.stateOpening);
addCommandValue(stateValues, channelConfiguration.stateClosed);
addCommandValue(stateValues, channelConfiguration.stateClosing);
stateValue = new TextValue(stateValues.toArray(new String[0]), commandValues.toArray(new String[0]));
final var rawStateChannel = buildChannel(RAW_STATE_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(),
"State", this).stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos(), channelConfiguration.commandTemplate)
.build(false);
hiddenChannels.add(rawStateChannel);
// If valve doesn't support stop, and can't report in-progress states, we don't need an exposed channel for
// state
if (channelConfiguration.payloadStop != null || channelConfiguration.stateOpening != null
|| channelConfiguration.stateClosing != null) {
buildChannel(STATE_CHANNEL_ID, ComponentChannelType.STRING, stateValue, "State", this)
.withAutoUpdatePolicy(autoUpdatePolicy).isAdvanced(true).commandFilter(command -> {
// OPEN and CLOSE need to be sent as 0/100 for positional valves
if (channelConfiguration.reportsPosition && command instanceof StringType commandStr) {
if (command.equals(channelConfiguration.payloadOpen)) {
command = PercentType.HUNDRED;
} else if (command.equals(channelConfiguration.payloadClose)) {
command = PercentType.ZERO;
}
}
rawStateChannel.getState().publishValue(command);
return false;
}).build();
}
finalizeChannels();
}
private void addCommandValue(List<String> commandValues, @Nullable String command) {
if (command != null) {
commandValues.add(command);
}
}
// Valve uses a dynamic format for the payload coming through, so we have to apply
// significant additional logic here.
// It can be JSON, in which case state and/or position can be supplied
// Or it can be a raw value - either a literal state, or a numeric position.
// If it's a numeric position (and wasn't JSON that had both state and position)
// then the state gets inferred from the position if it's fully open or fully closed.
@Override
public void updateChannelState(ChannelUID channel, State state) {
Set<String> states = stateValue.getStates();
@Nullable
String statePayload = state.toString();
Map<String, String> json = toJson(statePayload);
// have JSON; we can clearly know what's state and what's position
if (json != null) {
statePayload = json.get(STATE_KEY);
String positionPayload = json.get(POSITION_KEY);
if (channelConfiguration.reportsPosition && positionPayload == null) {
logger.warn("Missing required `position` attribute in json payload on topic '{}'",
channelConfiguration.stateTopic);
return;
}
if (!channelConfiguration.reportsPosition && statePayload == null) {
logger.warn("Missing required `state` attribute in json payload on topic '{}'",
channelConfiguration.stateTopic);
return;
}
// We have both state and position; no need to guess anything
if (channelConfiguration.reportsPosition) {
if (statePayload != null) {
if (states != null && !states.contains(statePayload)) {
logger.warn("Invalid state '{}' for {}", statePayload, getHaID().toShortTopic());
} else {
stateValue.update(new StringType(statePayload));
channelStateUpdateListener.updateChannelState(buildChannelUID(STATE_CHANNEL_ID),
stateValue.getChannelState());
}
try {
positionValue.update((State) positionValue
.parseMessage(DecimalType.valueOf(Objects.requireNonNull(positionPayload))));
channelStateUpdateListener.updateChannelState(buildChannelUID(VALVE_CHANNEL_ID),
positionValue.getChannelState());
} catch (IllegalArgumentException e) {
logger.warn("Ignoring non numeric payload '{}' received on topic '{}'", positionPayload,
channelConfiguration.stateTopic);
}
return;
} else {
statePayload = positionPayload;
}
}
}
statePayload = Objects.requireNonNull(statePayload);
if (states != null && states.contains(statePayload)) {
if (channelConfiguration.reportsPosition) {
if (statePayload.equals(channelConfiguration.stateClosed)) {
positionValue.update(PercentType.ZERO);
channelStateUpdateListener.updateChannelState(buildChannelUID(VALVE_CHANNEL_ID),
positionValue.getChannelState());
} else if (statePayload.equals(channelConfiguration.stateOpen)) {
positionValue.update(PercentType.HUNDRED);
channelStateUpdateListener.updateChannelState(buildChannelUID(VALVE_CHANNEL_ID),
positionValue.getChannelState());
}
} else {
if (statePayload.equals(channelConfiguration.stateClosed)) {
onOffValue.update(OnOffType.OFF);
} else {
onOffValue.update(OnOffType.ON);
}
channelStateUpdateListener.updateChannelState(buildChannelUID(VALVE_CHANNEL_ID),
onOffValue.getChannelState());
}
stateValue.update(new StringType(statePayload));
channelStateUpdateListener.updateChannelState(buildChannelUID(STATE_CHANNEL_ID),
stateValue.getChannelState());
return;
}
if (channelConfiguration.reportsPosition) {
// The state isn't a given state; it must be a number
try {
positionValue.update((State) positionValue.parseMessage(DecimalType.valueOf(statePayload)));
} catch (IllegalArgumentException e) {
logger.warn("Ignoring non numeric payload '{}' received on topic '{}'", statePayload,
channelConfiguration.stateTopic);
return;
}
channelStateUpdateListener.updateChannelState(buildChannelUID(VALVE_CHANNEL_ID),
positionValue.getChannelState());
if (positionValue.getChannelState().equals(PercentType.ZERO)) {
stateValue.update(new StringType(channelConfiguration.stateClosed));
channelStateUpdateListener.updateChannelState(buildChannelUID(STATE_CHANNEL_ID),
stateValue.getChannelState());
} else if (positionValue.getChannelState().equals(PercentType.HUNDRED)
|| stateValue.getChannelState().equals(channelConfiguration.stateClosed)) {
// Specifically set up to _not_ overwrite "opening" or "closing", but to set to "open"
// if we're full-open, or if it was previously "closed"
stateValue.update(new StringType(channelConfiguration.stateOpen));
channelStateUpdateListener.updateChannelState(buildChannelUID(STATE_CHANNEL_ID),
stateValue.getChannelState());
}
return;
}
logger.warn("Invalid state '{}' for {}", statePayload, getHaID().toShortTopic());
}
@Override
public void postChannelCommand(ChannelUID channelUID, Command value) {
throw new UnsupportedOperationException();
}
@Override
public void triggerChannel(ChannelUID channelUID, String eventPayload) {
throw new UnsupportedOperationException();
}
private @Nullable Map<String, String> toJson(String payload) {
try {
JsonNode node = objectMapper.readTree(payload);
if (node.getNodeType() != JsonNodeType.OBJECT) {
// numbers look like JSON, but we don't want to treat them as such
return null;
}
Map<String, String> result = new HashMap<>();
Iterator<Entry<String, JsonNode>> it = node.fields();
while (it.hasNext()) {
Entry<String, JsonNode> field = it.next();
result.put(field.getKey(), field.getValue().asText());
}
return result;
} catch (IOException e) {
// It's not JSON
return null;
}
}
}

View File

@ -0,0 +1,221 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.mqtt.homeassistant.internal.component;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
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.PercentageValue;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StringType;
/**
* Tests for {@link Valve}
*
* @author Cody Cutrer - Initial contribution
*/
@NonNullByDefault
public class ValveTests extends AbstractComponentTests {
public static final String CONFIG_TOPIC = "valve/water_valve";
@SuppressWarnings("null")
@Test
public void testSimple() {
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
{
"name": "MQTT valve",
"command_topic": "home-assistant/valve/set",
"state_topic": "home-assistant/valve/state",
"availability": [{
"topic": "home-assistant/valve/availability"
}],
"qos": 0,
"reports_position": false,
"retain": true,
"payload_open": "OPEN",
"payload_close": "CLOSE",
"payload_stop": "STOP",
"state_open": "open",
"state_opening": "opening",
"state_closed": "closed",
"state_closing": "closing",
"payload_available": "online",
"payload_not_available": "offline",
"optimistic": false,
"value_template": "{{ value_json.x }}"
}
""");
assertThat(component.channels.size(), is(2));
assertThat(component.getName(), is("MQTT valve"));
assertChannel(component, Valve.VALVE_CHANNEL_ID, "", "home-assistant/valve/set", "MQTT valve",
OnOffValue.class);
assertChannel(component, Valve.STATE_CHANNEL_ID, "", "", "State", TextValue.class);
publishMessage("home-assistant/valve/state", "{\"x\": \"open\"}");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.ON);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("home-assistant/valve/state", "{\"x\": \"closed\"}");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.OFF);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("closed"));
publishMessage("home-assistant/valve/state", "{\"x\": \"opening\"}");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.ON);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("opening"));
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(OnOffType.ON);
assertPublished("home-assistant/valve/set", "OPEN");
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
assertPublished("home-assistant/valve/set", "CLOSE");
component.getChannel(Valve.STATE_CHANNEL_ID).getState().publishValue(new StringType("OPEN"));
assertPublished("home-assistant/valve/set", "OPEN", 2);
component.getChannel(Valve.STATE_CHANNEL_ID).getState().publishValue(new StringType("CLOSE"));
assertPublished("home-assistant/valve/set", "CLOSE", 2);
component.getChannel(Valve.STATE_CHANNEL_ID).getState().publishValue(new StringType("STOP"));
assertPublished("home-assistant/valve/set", "STOP");
}
@SuppressWarnings("null")
@Test
public void testJsonWithSimple() {
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
{
"name": "MQTT valve",
"command_topic": "home-assistant/valve/set",
"state_topic": "home-assistant/valve/state"
}
""");
assertThat(component.channels.size(), is(2));
assertThat(component.getName(), is("MQTT valve"));
assertChannel(component, Valve.VALVE_CHANNEL_ID, "", "home-assistant/valve/set", "MQTT valve",
OnOffValue.class);
assertChannel(component, Valve.STATE_CHANNEL_ID, "", "", "State", TextValue.class);
publishMessage("home-assistant/valve/state", "{\"state\": \"open\"}");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.ON);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("home-assistant/valve/state", "{\"state\": \"closed\"}");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.OFF);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("closed"));
publishMessage("home-assistant/valve/state", "{\"state\": \"opening\"}");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.ON);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("opening"));
}
@SuppressWarnings("null")
@Test
public void testPositional() {
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
{
"name": "MQTT valve",
"command_topic": "home-assistant/valve/set",
"state_topic": "home-assistant/valve/state",
"reports_position": true
}
""");
assertThat(component.channels.size(), is(2));
assertThat(component.getName(), is("MQTT valve"));
assertChannel(component, Valve.VALVE_CHANNEL_ID, "", "home-assistant/valve/set", "MQTT valve",
PercentageValue.class);
assertChannel(component, Valve.STATE_CHANNEL_ID, "", "", "State", TextValue.class);
publishMessage("home-assistant/valve/state", "open");
assertState(component, Valve.VALVE_CHANNEL_ID, PercentType.HUNDRED);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("home-assistant/valve/state", "closed");
assertState(component, Valve.VALVE_CHANNEL_ID, PercentType.ZERO);
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("closed"));
publishMessage("home-assistant/valve/state", "50");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(50));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("home-assistant/valve/state", "opening");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(50));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("opening"));
publishMessage("home-assistant/valve/state", "75");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(75));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("opening"));
publishMessage("home-assistant/valve/state", "100");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(100));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("home-assistant/valve/state", "0");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(0));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("closed"));
// check JSON messages
publishMessage("home-assistant/valve/state", "{ \"position\": 50 }");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(50));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("home-assistant/valve/state", "{ \"position\": 20, \"state\": \"closing\" }");
assertState(component, Valve.VALVE_CHANNEL_ID, new PercentType(20));
assertState(component, Valve.STATE_CHANNEL_ID, new StringType("closing"));
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(OnOffType.ON);
assertPublished("home-assistant/valve/set", "100");
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
assertPublished("home-assistant/valve/set", "100", 2);
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(OnOffType.OFF);
assertPublished("home-assistant/valve/set", "0");
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
assertPublished("home-assistant/valve/set", "0", 2);
component.getChannel(Valve.VALVE_CHANNEL_ID).getState().publishValue(new PercentType(50));
assertPublished("home-assistant/valve/set", "50");
component.getChannel(Valve.STATE_CHANNEL_ID).getState().publishValue(new StringType("OPEN"));
assertPublished("home-assistant/valve/set", "100", 3);
component.getChannel(Valve.STATE_CHANNEL_ID).getState().publishValue(new StringType("CLOSE"));
assertPublished("home-assistant/valve/set", "0", 3);
component.getChannel(Valve.STATE_CHANNEL_ID).getState().publishValue(new StringType("STOP"));
assertPublished("home-assistant/valve/set", "STOP");
}
@SuppressWarnings("null")
@Test
public void testNoState() {
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
{
"name": "MQTT valve",
"command_topic": "home-assistant/valve/set",
"state_topic": "home-assistant/valve/state",
"state_opening": null,
"state_closing": null
}
""");
assertThat(component.channels.size(), is(1));
assertThat(component.getName(), is("MQTT valve"));
assertChannel(component, Valve.VALVE_CHANNEL_ID, "", "home-assistant/valve/set", "MQTT valve",
OnOffValue.class);
publishMessage("home-assistant/valve/state", "open");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.ON);
publishMessage("home-assistant/valve/state", "closed");
assertState(component, Valve.VALVE_CHANNEL_ID, OnOffType.OFF);
}
@Override
protected Set<String> getConfigTopics() {
return Set.of(CONFIG_TOPIC);
}
}