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);