diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/README.md b/bundles/org.openhab.binding.mqtt.homeassistant/README.md index f65261c18bf62..6a6a74f661744 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/README.md +++ b/bundles/org.openhab.binding.mqtt.homeassistant/README.md @@ -22,14 +22,14 @@ You can also manually create a Thing, and provide the individual component topic - [Cover](https://www.home-assistant.io/integrations/cover.mqtt/) - [Device Trigger](https://www.home-assistant.io/integrations/device_trigger.mqtt/) - [Fan](https://www.home-assistant.io/integrations/fan.mqtt/)
- Only ON/OFF is supported. JSON attributes are not supported. -- [Light](https://www.home-assistant.io/integrations/light.mqtt/)
- Template schema is not supported. Command templates only have access to the `value` variable. + JSON attributes are not supported. +- [Light](https://www.home-assistant.io/integrations/light.mqtt/) - [Lock](https://www.home-assistant.io/integrations/lock.mqtt/) - [Number](https://www.home-assistant.io/integrations/number.mqtt/) - [Scene](https://www.home-assistant.io/integrations/scene.mqtt/) - [Select](https://www.home-assistant.io/integrations/select.mqtt/) -- [Sensor](https://www.home-assistant.io/integrations/sensor.mqtt/) +- [Sensor](https://www.home-assistant.io/integrations/sensor.mqtt/)
+ JSON attributes are not supported. - [Switch](https://www.home-assistant.io/integrations/switch.mqtt/) - [Update](https://www.home-assistant.io/integrations/update.mqtt/)
This is a special component, that will show up as additional properties on the Thing, and add a button on the Thing to initiate an OTA update. diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java index 6b216a2bcb3c1..24f9d8ee16608 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java @@ -12,11 +12,24 @@ */ package org.openhab.binding.mqtt.homeassistant.internal.component; +import java.math.BigDecimal; +import java.util.List; + 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.ComponentChannel; import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; import com.google.gson.annotations.SerializedName; @@ -28,8 +41,12 @@ * @author David Graeff - Initial contribution */ @NonNullByDefault -public class Fan extends AbstractComponent { - public static final String SWITCH_CHANNEL_ID = "fan"; // Randomly chosen channel "ID" +public class Fan extends AbstractComponent implements ChannelStateUpdateListener { + public static final String SWITCH_CHANNEL_ID = "fan"; + public static final String SPEED_CHANNEL_ID = "speed"; + public static final String PRESET_MODE_CHANNEL_ID = "preset_mode"; + public static final String OSCILLATION_CHANNEL_ID = "oscillation"; + public static final String DIRECTION_CHANNEL_ID = "direction"; /** * Configuration class for MQTT component @@ -45,21 +62,168 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { protected @Nullable String commandTemplate; @SerializedName("command_topic") protected String commandTopic = ""; - @SerializedName("payload_on") - protected String payloadOn = "ON"; + @SerializedName("direction_command_template") + protected @Nullable String directionCommandTemplate; + @SerializedName("direction_command_topic") + protected @Nullable String directionCommandTopic; + @SerializedName("direction_state_topic") + protected @Nullable String directionStateTopic; + @SerializedName("direction_value_template") + protected @Nullable String directionValueTemplate; + @SerializedName("oscillation_command_template") + protected @Nullable String oscillationCommandTemplate; + @SerializedName("oscillation_command_topic") + protected @Nullable String oscillationCommandTopic; + @SerializedName("oscillation_state_topic") + protected @Nullable String oscillationStateTopic; + @SerializedName("oscillation_value_template") + protected @Nullable String oscillationValueTemplate; + @SerializedName("payload_oscillation_off") + protected String payloadOscillationOff = "oscillate_off"; + @SerializedName("payload_oscillation_on") + protected String payloadOscillationOn = "oscillate_on"; @SerializedName("payload_off") protected String payloadOff = "OFF"; + @SerializedName("payload_on") + protected String payloadOn = "ON"; + @SerializedName("payload_reset_percentage") + protected String payloadResetPercentage = "None"; + @SerializedName("payload_reset_preset_mode") + protected String payloadResetPresetMode = "None"; + @SerializedName("percentage_command_template") + protected @Nullable String percentageCommandTemplate; + @SerializedName("percentage_command_topic") + protected @Nullable String percentageCommandTopic; + @SerializedName("percentage_state_topic") + protected @Nullable String percentageStateTopic; + @SerializedName("percentage_value_template") + protected @Nullable String percentageValueTemplate; + @SerializedName("preset_mode_command_template") + protected @Nullable String presetModeCommandTemplate; + @SerializedName("preset_mode_command_topic") + protected @Nullable String presetModeCommandTopic; + @SerializedName("preset_mode_state_topic") + protected @Nullable String presetModeStateTopic; + @SerializedName("preset_mode_value_template") + protected @Nullable String presetModeValueTemplate; + @SerializedName("preset_modes") + protected @Nullable List presetModes; + @SerializedName("speed_range_max") + protected int speedRangeMax = 100; + @SerializedName("speed_range_min") + protected int speedRangeMin = 1; } + private final OnOffValue onOffValue; + private final PercentageValue speedValue; + private State rawSpeedState; + private final ComponentChannel onOffChannel; + private final ChannelStateUpdateListener channelStateUpdateListener; + public Fan(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); + this.channelStateUpdateListener = componentConfiguration.getUpdateListener(); - OnOffValue value = new OnOffValue(channelConfiguration.payloadOn, channelConfiguration.payloadOff); - buildChannel(SWITCH_CHANNEL_ID, ComponentChannelType.SWITCH, value, getName(), - componentConfiguration.getUpdateListener()) + onOffValue = new OnOffValue(channelConfiguration.payloadOn, channelConfiguration.payloadOff); + ChannelStateUpdateListener onOffListener = channelConfiguration.percentageCommandTopic == null + ? componentConfiguration.getUpdateListener() + : this; + onOffChannel = buildChannel(SWITCH_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State", + onOffListener) .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.commandTemplate) - .build(); + .build(channelConfiguration.percentageCommandTopic == null); + + rawSpeedState = UnDefType.NULL; + + int speeds = Math.min(channelConfiguration.speedRangeMax, 100) - Math.max(channelConfiguration.speedRangeMin, 1) + + 1; + speedValue = new PercentageValue(BigDecimal.ZERO, BigDecimal.valueOf(100), BigDecimal.valueOf(100.0d / speeds), + channelConfiguration.payloadOn, channelConfiguration.payloadOff); + + if (channelConfiguration.percentageCommandTopic != null) { + hiddenChannels.add(onOffChannel); + buildChannel(SPEED_CHANNEL_ID, ComponentChannelType.DIMMER, speedValue, "Speed", this) + .stateTopic(channelConfiguration.percentageStateTopic, channelConfiguration.percentageValueTemplate) + .commandTopic(channelConfiguration.percentageCommandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.percentageCommandTemplate) + .commandFilter(this::handlePercentageCommand).build(); + } + + List presetModes = channelConfiguration.presetModes; + if (presetModes != null) { + TextValue presetModeValue = new TextValue(presetModes.toArray(new String[0])); + presetModeValue.setNullValue(channelConfiguration.payloadResetPresetMode); + buildChannel(PRESET_MODE_CHANNEL_ID, ComponentChannelType.STRING, presetModeValue, "Preset Mode", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.presetModeStateTopic, channelConfiguration.presetModeValueTemplate) + .commandTopic(channelConfiguration.presetModeCommandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.presetModeCommandTemplate) + .build(); + } + + if (channelConfiguration.oscillationCommandTopic != null) { + OnOffValue oscillationValue = new OnOffValue(channelConfiguration.payloadOscillationOn, + channelConfiguration.payloadOscillationOff); + buildChannel(OSCILLATION_CHANNEL_ID, ComponentChannelType.SWITCH, oscillationValue, "Oscillation", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.oscillationStateTopic, + channelConfiguration.oscillationValueTemplate) + .commandTopic(channelConfiguration.oscillationCommandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.oscillationCommandTemplate) + .build(); + } + + if (channelConfiguration.directionCommandTopic != null) { + TextValue directionValue = new TextValue(new String[] { "forward", "backward" }); + buildChannel(DIRECTION_CHANNEL_ID, ComponentChannelType.STRING, directionValue, "Direction", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.directionStateTopic, channelConfiguration.directionValueTemplate) + .commandTopic(channelConfiguration.directionCommandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.directionCommandTemplate) + .build(); + } + } + + private boolean handlePercentageCommand(Command command) { + // ON/OFF go to the regular command topic, not the percentage topic + if (command.equals(OnOffType.ON) || command.equals(OnOffType.OFF)) { + onOffChannel.getState().publishValue(command); + return false; + } + return true; + } + + @Override + public void updateChannelState(ChannelUID channel, State state) { + if (channel.getIdWithoutGroup().equals(SWITCH_CHANNEL_ID)) { + if (rawSpeedState instanceof UnDefType && state.equals(OnOffType.ON)) { + // Assume full on if we don't yet know the actual speed + state = PercentType.HUNDRED; + } else if (state.equals(OnOffType.OFF)) { + state = PercentType.ZERO; + } else { + state = rawSpeedState; + } + } else if (channel.getIdWithoutGroup().equals(SPEED_CHANNEL_ID)) { + rawSpeedState = state; + if (onOffValue.getChannelState().equals(OnOffType.OFF)) { + // Don't pass on percentage values while the fan is off + state = PercentType.ZERO; + } + } + speedValue.update(state); + channelStateUpdateListener.updateChannelState(buildChannelUID(SPEED_CHANNEL_ID), state); + } + + @Override + public void postChannelCommand(ChannelUID channelUID, Command value) { + throw new UnsupportedOperationException(); + } + + @Override + public void triggerChannel(ChannelUID channelUID, String eventPayload) { + throw new UnsupportedOperationException(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java index 3286b8141c59a..5d9b0a2e9ced8 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java @@ -15,12 +15,19 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import java.math.BigDecimal; +import java.util.Objects; 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; +import org.openhab.core.types.UnDefType; /** * Tests for {@link Fan} @@ -64,8 +71,8 @@ public void test() throws InterruptedException { assertThat(component.channels.size(), is(1)); assertThat(component.getName(), is("fan")); - assertChannel(component, Fan.SWITCH_CHANNEL_ID, "zigbee2mqtt/fan/state", "zigbee2mqtt/fan/set/state", "fan", - OnOffValue.class); + assertChannel(component, Fan.SWITCH_CHANNEL_ID, "zigbee2mqtt/fan/state", "zigbee2mqtt/fan/set/state", + "On/Off State", OnOffValue.class); publishMessage("zigbee2mqtt/fan/state", "ON_"); assertState(component, Fan.SWITCH_CHANNEL_ID, OnOffType.ON); @@ -116,6 +123,116 @@ public void testCommandTemplate() throws InterruptedException { assertPublished("zigbee2mqtt/fan/set/state", "set to OFF_"); } + @SuppressWarnings("null") + @Test + public void testComplex() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "availability": [ + { + "topic": "zigbee2mqtt/bridge/state" + } + ], + "device": { + "identifiers": [ + "zigbee2mqtt_0x0000000000000000" + ], + "manufacturer": "Fans inc", + "model": "Fan", + "name": "FanBlower", + "sw_version": "Zigbee2MQTT 1.18.2" + }, + "name": "Bedroom Fan", + "payload_off": "false", + "payload_on": "true", + "state_topic": "bedroom_fan/on/state", + "command_topic": "bedroom_fan/on/set", + "direction_state_topic": "bedroom_fan/direction/state", + "direction_command_topic": "bedroom_fan/direction/set", + "oscillation_state_topic": "bedroom_fan/oscillation/state", + "oscillation_command_topic": "bedroom_fan/oscillation/set", + "percentage_state_topic": "bedroom_fan/speed/percentage_state", + "percentage_command_topic": "bedroom_fan/speed/percentage", + "preset_mode_state_topic": "bedroom_fan/preset/preset_mode_state", + "preset_mode_command_topic": "bedroom_fan/preset/preset_mode", + "preset_modes": [ + "auto", + "smart", + "whoosh", + "eco", + "breeze" + ], + "payload_oscillation_on": "true", + "payload_oscillation_off": "false", + "speed_range_min": 1, + "speed_range_max": 10 + } + """); + + assertThat(component.channels.size(), is(4)); + assertThat(component.getName(), is("Bedroom Fan")); + + assertChannel(component, Fan.SPEED_CHANNEL_ID, "bedroom_fan/speed/percentage_state", + "bedroom_fan/speed/percentage", "Speed", PercentageValue.class); + var channel = Objects.requireNonNull(component.getChannel(Fan.SPEED_CHANNEL_ID)); + assertThat(channel.getStateDescription().getStep(), is(BigDecimal.valueOf(10.0d))); + assertChannel(component, Fan.OSCILLATION_CHANNEL_ID, "bedroom_fan/oscillation/state", + "bedroom_fan/oscillation/set", "Oscillation", OnOffValue.class); + assertChannel(component, Fan.DIRECTION_CHANNEL_ID, "bedroom_fan/direction/state", "bedroom_fan/direction/set", + "Direction", TextValue.class); + assertChannel(component, Fan.PRESET_MODE_CHANNEL_ID, "bedroom_fan/preset/preset_mode_state", + "bedroom_fan/preset/preset_mode", "Preset Mode", TextValue.class); + + publishMessage("bedroom_fan/on/state", "true"); + assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.HUNDRED); + publishMessage("bedroom_fan/on/state", "false"); + assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO); + publishMessage("bedroom_fan/on/state", "true"); + publishMessage("bedroom_fan/speed/percentage_state", "50"); + assertState(component, Fan.SPEED_CHANNEL_ID, new PercentType(50)); + publishMessage("bedroom_fan/on/state", "false"); + // Off, even though we got an updated speed + assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO); + publishMessage("bedroom_fan/speed/percentage_state", "25"); + assertState(component, Fan.SPEED_CHANNEL_ID, PercentType.ZERO); + publishMessage("bedroom_fan/on/state", "true"); + // Now that it's on, the channel reflects the proper speed + assertState(component, Fan.SPEED_CHANNEL_ID, new PercentType(25)); + + publishMessage("bedroom_fan/oscillation/state", "true"); + assertState(component, Fan.OSCILLATION_CHANNEL_ID, OnOffType.ON); + publishMessage("bedroom_fan/oscillation/state", "false"); + assertState(component, Fan.OSCILLATION_CHANNEL_ID, OnOffType.OFF); + + publishMessage("bedroom_fan/direction/state", "forward"); + assertState(component, Fan.DIRECTION_CHANNEL_ID, new StringType("forward")); + publishMessage("bedroom_fan/direction/state", "backward"); + assertState(component, Fan.DIRECTION_CHANNEL_ID, new StringType("backward")); + + publishMessage("bedroom_fan/preset/preset_mode_state", "auto"); + assertState(component, Fan.PRESET_MODE_CHANNEL_ID, new StringType("auto")); + publishMessage("bedroom_fan/preset/preset_mode_state", "None"); + assertState(component, Fan.PRESET_MODE_CHANNEL_ID, UnDefType.NULL); + + component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(OnOffType.OFF); + assertPublished("bedroom_fan/on/set", "false"); + component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(OnOffType.ON); + assertPublished("bedroom_fan/on/set", "true"); + // Setting to a specific speed turns it on first + component.getChannel(Fan.SPEED_CHANNEL_ID).getState().publishValue(PercentType.HUNDRED); + assertPublished("bedroom_fan/on/set", "true"); + assertPublished("bedroom_fan/speed/percentage", "100"); + + component.getChannel(Fan.OSCILLATION_CHANNEL_ID).getState().publishValue(OnOffType.ON); + assertPublished("bedroom_fan/oscillation/set", "true"); + + component.getChannel(Fan.DIRECTION_CHANNEL_ID).getState().publishValue(new StringType("forward")); + assertPublished("bedroom_fan/direction/set", "forward"); + + component.getChannel(Fan.PRESET_MODE_CHANNEL_ID).getState().publishValue(new StringType("eco")); + assertPublished("bedroom_fan/preset/preset_mode", "eco"); + } + @Override protected Set getConfigTopics() { return Set.of(CONFIG_TOPIC);