[mqtt.homeassistant] Improve Cover support (#15875)

* [mqtt.homeassistant] improve Cover support

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

* add constants to clarify up/down values

* Be sure to parse percents from strings in RollshutterValue

---------

Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer
2023-12-11 11:11:27 -07:00
committed by GitHub
parent 0aab1f5818
commit 73559be058
6 changed files with 358 additions and 80 deletions

View File

@@ -12,6 +12,7 @@
*/
package org.openhab.binding.mqtt.homeassistant.internal.component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -19,6 +20,7 @@ import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -26,7 +28,6 @@ import org.openhab.binding.mqtt.generic.AvailabilityTracker;
import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
import org.openhab.binding.mqtt.generic.MqttChannelTypeProvider;
import org.openhab.binding.mqtt.generic.TransformationServiceProvider;
import org.openhab.binding.mqtt.generic.utils.FutureCollector;
import org.openhab.binding.mqtt.generic.values.Value;
import org.openhab.binding.mqtt.homeassistant.generic.internal.MqttBindingConstants;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
@@ -66,6 +67,8 @@ public abstract class AbstractComponent<C extends AbstractChannelConfiguration>
// Channels and configuration
protected final Map<String, ComponentChannel> channels = new TreeMap<>();
protected final List<ComponentChannel> hiddenChannels = new ArrayList<>();
// The hash code ({@link String#hashCode()}) of the configuration string
// Used to determine if a component has changed.
protected final int configHash;
@@ -155,8 +158,9 @@ public abstract class AbstractComponent<C extends AbstractChannelConfiguration>
*/
public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
int timeout) {
return channels.values().stream().map(cChannel -> cChannel.start(connection, scheduler, timeout))
.collect(FutureCollector.allOf());
return Stream.concat(channels.values().stream(), hiddenChannels.stream())
.map(v -> v.start(connection, scheduler, timeout)) //
.reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
}
/**
@@ -166,7 +170,10 @@ public abstract class AbstractComponent<C extends AbstractChannelConfiguration>
* exceptionally on errors.
*/
public CompletableFuture<@Nullable Void> stop() {
return channels.values().stream().map(ComponentChannel::stop).collect(FutureCollector.allOf());
return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
.filter(Objects::nonNull) //
.map(ComponentChannel::stop) //
.reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
}
/**

View File

@@ -15,20 +15,29 @@ package org.openhab.binding.mqtt.homeassistant.internal.component;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.values.RollershutterValue;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
import org.openhab.core.library.types.StopMoveType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
import com.google.gson.annotations.SerializedName;
/**
* A MQTT Cover component, following the https://www.home-assistant.io/components/cover.mqtt/ specification.
* A MQTT Cover component, following the https://www.home-assistant.io/integrations/cover.mqtt specification.
*
* Only Open/Close/Stop works so far.
* Supports reporting state and/or position, and commanding OPEN/CLOSE/STOP
*
* Does not yet support tilt or covers that don't go from 0-100.
*
* @author David Graeff - Initial contribution
* @author Cody Cutrer - Add support for position and discrete state strings
*/
@NonNullByDefault
public class Cover extends AbstractComponent<Cover.ChannelConfiguration> {
public static final String SWITCH_CHANNEL_ID = "cover"; // Randomly chosen channel "ID"
public static final String COVER_CHANNEL_ID = "cover";
public static final String STATE_CHANNEL_ID = "state";
/**
* Configuration class for MQTT component
@@ -48,18 +57,97 @@ public class Cover extends AbstractComponent<Cover.ChannelConfiguration> {
protected String payloadClose = "CLOSE";
@SerializedName("payload_stop")
protected String payloadStop = "STOP";
@SerializedName("position_closed")
protected int positionClosed = 0;
@SerializedName("position_open")
protected int positionOpen = 100;
@SerializedName("position_template")
protected @Nullable String positionTemplate;
@SerializedName("position_topic")
protected @Nullable String positionTopic;
@SerializedName("set_position_template")
protected @Nullable String setPositionTemplate;
@SerializedName("set_position_topic")
protected @Nullable String setPositionTopic;
@SerializedName("state_closed")
protected String stateClosed = "closed";
@SerializedName("state_closing")
protected String stateClosing = "closing";
@SerializedName("state_open")
protected String stateOpen = "open";
@SerializedName("state_opening")
protected String stateOpening = "opening";
@SerializedName("state_stopped")
protected String stateStopped = "stopped";
}
@Nullable
ComponentChannel stateChannel = null;
public Cover(ComponentFactory.ComponentConfiguration componentConfiguration) {
super(componentConfiguration, ChannelConfiguration.class);
RollershutterValue value = new RollershutterValue(channelConfiguration.payloadOpen,
channelConfiguration.payloadClose, channelConfiguration.payloadStop);
String stateTopic = channelConfiguration.stateTopic;
buildChannel(SWITCH_CHANNEL_ID, value, getName(), componentConfiguration.getUpdateListener())
.stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate())
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos())
.build();
// State can indicate additional information than just
// the current position, so expose it as a separate channel
if (stateTopic != null) {
TextValue value = new TextValue(new String[] { channelConfiguration.stateClosed,
channelConfiguration.stateClosing, channelConfiguration.stateOpen,
channelConfiguration.stateOpening, channelConfiguration.stateStopped });
buildChannel(STATE_CHANNEL_ID, value, "State", componentConfiguration.getUpdateListener())
.stateTopic(stateTopic).isAdvanced(true).build();
}
if (channelConfiguration.commandTopic != null) {
hiddenChannels.add(stateChannel = buildChannel(STATE_CHANNEL_ID, new TextValue(), "State",
componentConfiguration.getUpdateListener())
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
channelConfiguration.getQos())
.build(false));
} else {
// no command topic. we need to make sure we send
// integers for open and close
channelConfiguration.payloadOpen = String.valueOf(channelConfiguration.positionOpen);
channelConfiguration.payloadClose = String.valueOf(channelConfiguration.positionClosed);
}
// We will either have positionTopic or stateTopic.
// positionTopic is more useful, but if we only have stateTopic,
// still build a Rollershutter channel so that UP/DOWN/STOP
// commands can be sent
String rollershutterStateTopic = channelConfiguration.positionTopic;
String stateTemplate = channelConfiguration.positionTemplate;
if (rollershutterStateTopic == null) {
rollershutterStateTopic = stateTopic;
stateTemplate = channelConfiguration.getValueTemplate();
}
String rollershutterCommandTopic = channelConfiguration.setPositionTopic;
if (rollershutterCommandTopic == null) {
rollershutterCommandTopic = channelConfiguration.commandTopic;
}
boolean inverted = channelConfiguration.positionOpen > channelConfiguration.positionClosed;
final RollershutterValue value = new RollershutterValue(channelConfiguration.payloadOpen,
channelConfiguration.payloadClose, channelConfiguration.payloadStop, channelConfiguration.stateOpen,
channelConfiguration.stateClosed, inverted, channelConfiguration.setPositionTopic == null);
buildChannel(COVER_CHANNEL_ID, value, "Cover", componentConfiguration.getUpdateListener())
.stateTopic(rollershutterStateTopic, stateTemplate)
.commandTopic(rollershutterCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos())
.commandFilter(command -> {
if (stateChannel == null) {
return true;
}
// If we have a state channel, and this is UP/DOWN/STOP, then
// we need to send the command to _that_ channel's topic, not
// the position topic.
if (command instanceof UpDownType || command instanceof StopMoveType) {
command = new StringType(value.getMQTTpublishValue(command, false));
stateChannel.getState().publishValue(command);
return false;
}
return true;
}).build();
}
}

View File

@@ -13,12 +13,7 @@
package org.openhab.binding.mqtt.homeassistant.internal.component;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@@ -32,7 +27,6 @@ import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException;
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.types.Command;
@@ -249,7 +243,6 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
protected final @Nullable TextValue effectValue;
protected final ColorValue colorValue = new ColorValue(ColorMode.HSB, null, null, 100);
protected final List<ComponentChannel> hiddenChannels = new ArrayList<>();
protected final ChannelStateUpdateListener channelStateUpdateListener;
public static Light create(ComponentFactory.ComponentConfiguration builder) throws UnsupportedComponentException {
@@ -302,22 +295,6 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
protected abstract void buildChannels();
@Override
public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
int timeout) {
return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
.map(v -> v.start(connection, scheduler, timeout)) //
.reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
}
@Override
public CompletableFuture<@Nullable Void> stop() {
return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
.filter(Objects::nonNull) //
.map(ComponentChannel::stop) //
.reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
}
@Override
public void postChannelCommand(ChannelUID channelUID, Command value) {
throw new UnsupportedOperationException();

View File

@@ -20,8 +20,11 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.mqtt.generic.values.RollershutterValue;
import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StopMoveType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
/**
* Tests for {@link Cover}
@@ -34,7 +37,7 @@ public class CoverTests extends AbstractComponentTests {
@SuppressWarnings("null")
@Test
public void test() throws InterruptedException {
public void testStateOnly() throws InterruptedException {
// @formatter:off
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
"""
@@ -63,27 +66,135 @@ public class CoverTests extends AbstractComponentTests {
""");
// @formatter:on
assertThat(component.channels.size(), is(1));
assertThat(component.channels.size(), is(2));
assertThat(component.getName(), is("cover"));
assertChannel(component, Cover.SWITCH_CHANNEL_ID, "zigbee2mqtt/cover/state", "zigbee2mqtt/cover/set/state",
"cover", RollershutterValue.class);
assertChannel(component, Cover.STATE_CHANNEL_ID, "zigbee2mqtt/cover/state", "", "State", TextValue.class);
assertChannel(component, Cover.COVER_CHANNEL_ID, "zigbee2mqtt/cover/state", "zigbee2mqtt/cover/set/state",
"Cover", RollershutterValue.class);
publishMessage("zigbee2mqtt/cover/state", "100");
assertState(component, Cover.SWITCH_CHANNEL_ID, PercentType.HUNDRED);
publishMessage("zigbee2mqtt/cover/state", "0");
assertState(component, Cover.SWITCH_CHANNEL_ID, PercentType.ZERO);
publishMessage("zigbee2mqtt/cover/state", "closed");
assertState(component, Cover.COVER_CHANNEL_ID, UpDownType.DOWN);
assertState(component, Cover.STATE_CHANNEL_ID, new StringType("closed"));
publishMessage("zigbee2mqtt/cover/state", "open");
assertState(component, Cover.STATE_CHANNEL_ID, new StringType("open"));
assertState(component, Cover.COVER_CHANNEL_ID, UpDownType.UP);
component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.UP);
assertPublished("zigbee2mqtt/cover/set/state", "OPEN_");
component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.DOWN);
assertPublished("zigbee2mqtt/cover/set/state", "CLOSE_");
component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
assertPublished("zigbee2mqtt/cover/set/state", "STOP_");
component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
assertPublished("zigbee2mqtt/cover/set/state", "OPEN_", 2);
component.getChannel(Cover.SWITCH_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
assertPublished("zigbee2mqtt/cover/set/state", "STOP_", 2);
}
@SuppressWarnings("null")
@Test
public void testPositionAndState() throws InterruptedException {
// @formatter:off
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
"""
{
"dev_cla":"garage",
"pos_t":"esphome/single-car-gdo/cover/door/position/state",
"set_pos_t":"esphome/single-car-gdo/cover/door/position/command",
"name":"Door",
"stat_t":"esphome/single-car-gdo/cover/door/state",
"cmd_t":"esphome/single-car-gdo/cover/door/command",
"avty_t":"esphome/single-car-gdo/status",
"uniq_id":"78e36d645710-cover-d27845ad",
"dev":{
"ids":"78e36d645710",
"name":"Single Car Garage Door Opener",
"sw":"esphome v2023.10.4 Nov 7 2023, 16:19:39",
"mdl":"esp32dev",
"mf":"espressif"}
}
""");
// @formatter:on
assertThat(component.channels.size(), is(2));
assertThat(component.getName(), is("Door"));
assertChannel(component, Cover.STATE_CHANNEL_ID, "esphome/single-car-gdo/cover/door/state", "", "State",
TextValue.class);
assertChannel(component, Cover.COVER_CHANNEL_ID, "esphome/single-car-gdo/cover/door/position/state",
"esphome/single-car-gdo/cover/door/position/command", "Cover", RollershutterValue.class);
publishMessage("esphome/single-car-gdo/cover/door/state", "closed");
assertState(component, Cover.STATE_CHANNEL_ID, new StringType("closed"));
publishMessage("esphome/single-car-gdo/cover/door/state", "open");
assertState(component, Cover.STATE_CHANNEL_ID, new StringType("open"));
publishMessage("esphome/single-car-gdo/cover/door/state", "opening");
assertState(component, Cover.STATE_CHANNEL_ID, new StringType("opening"));
publishMessage("esphome/single-car-gdo/cover/door/position/state", "100");
assertState(component, Cover.COVER_CHANNEL_ID, PercentType.ZERO);
publishMessage("esphome/single-car-gdo/cover/door/position/state", "40");
assertState(component, Cover.COVER_CHANNEL_ID, new PercentType(60));
publishMessage("esphome/single-car-gdo/cover/door/position/state", "0");
assertState(component, Cover.COVER_CHANNEL_ID, PercentType.HUNDRED);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
assertPublished("esphome/single-car-gdo/cover/door/position/command", "100");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
assertPublished("esphome/single-car-gdo/cover/door/position/command", "0");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(StopMoveType.STOP);
assertPublished("esphome/single-car-gdo/cover/door/command", "STOP");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.UP);
assertPublished("esphome/single-car-gdo/cover/door/command", "OPEN");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.DOWN);
assertPublished("esphome/single-car-gdo/cover/door/command", "CLOSE");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(new PercentType(40));
assertPublished("esphome/single-car-gdo/cover/door/position/command", "60");
}
@SuppressWarnings("null")
@Test
public void testPositionOnly() throws InterruptedException {
// @formatter:off
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
"""
{
"dev_cla":"garage",
"pos_t":"esphome/single-car-gdo/cover/door/position/state",
"set_pos_t":"esphome/single-car-gdo/cover/door/position/command",
"name":"Door",
"avty_t":"esphome/single-car-gdo/status",
"uniq_id":"78e36d645710-cover-d27845ad",
"dev":{
"ids":"78e36d645710",
"name":"Single Car Garage Door Opener",
"sw":"esphome v2023.10.4 Nov 7 2023, 16:19:39",
"mdl":"esp32dev",
"mf":"espressif"}
}
""");
// @formatter:on
assertThat(component.channels.size(), is(1));
assertThat(component.getName(), is("Door"));
assertChannel(component, Cover.COVER_CHANNEL_ID, "esphome/single-car-gdo/cover/door/position/state",
"esphome/single-car-gdo/cover/door/position/command", "Cover", RollershutterValue.class);
publishMessage("esphome/single-car-gdo/cover/door/position/state", "100");
assertState(component, Cover.COVER_CHANNEL_ID, PercentType.ZERO);
publishMessage("esphome/single-car-gdo/cover/door/position/state", "40");
assertState(component, Cover.COVER_CHANNEL_ID, new PercentType(60));
publishMessage("esphome/single-car-gdo/cover/door/position/state", "0");
assertState(component, Cover.COVER_CHANNEL_ID, PercentType.HUNDRED);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.ZERO);
assertPublished("esphome/single-car-gdo/cover/door/position/command", "100");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED);
assertPublished("esphome/single-car-gdo/cover/door/position/command", "0");
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.UP);
assertPublished("esphome/single-car-gdo/cover/door/position/command", "100", 2);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(UpDownType.DOWN);
assertPublished("esphome/single-car-gdo/cover/door/position/command", "0", 2);
component.getChannel(Cover.COVER_CHANNEL_ID).getState().publishValue(new PercentType(40));
assertPublished("esphome/single-car-gdo/cover/door/position/command", "60");
}
@Override