]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] Fully implement Fan component (#17402)
authorCody Cutrer <cody@cutrer.us>
Wed, 11 Sep 2024 20:36:43 +0000 (14:36 -0600)
committerGitHub <noreply@github.com>
Wed, 11 Sep 2024 20:36:43 +0000 (22:36 +0200)
Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.homeassistant/README.md
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java

index f65261c18bf626d2a62953bec3a798552761ef27..6a6a74f6617445486557ee3fea46651b20c387e2 100644 (file)
@@ -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/)<br>
-  Only ON/OFF is supported. JSON attributes are not supported.
-- [Light](https://www.home-assistant.io/integrations/light.mqtt/)<br>
-  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/)<br>
+  JSON attributes are not supported.
 - [Switch](https://www.home-assistant.io/integrations/switch.mqtt/)
 - [Update](https://www.home-assistant.io/integrations/update.mqtt/)<br>
   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.
index 6b216a2bcb3c1589e78ff666d6eddd6ea5d6756c..24f9d8ee166080bbf82a06a31a9fd3842ff70374 100644 (file)
  */
 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 @@ import com.google.gson.annotations.SerializedName;
  * @author David Graeff - Initial contribution
  */
 @NonNullByDefault
-public class Fan extends AbstractComponent<Fan.ChannelConfiguration> {
-    public static final String SWITCH_CHANNEL_ID = "fan"; // Randomly chosen channel "ID"
+public class Fan extends AbstractComponent<Fan.ChannelConfiguration> 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 @@ public class Fan extends AbstractComponent<Fan.ChannelConfiguration> {
         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<String> 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<String> 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();
     }
 }
index 3286b8141c59a16f3c64c07c56f858fb7a678609..5d9b0a2e9ced838a446889aaef424b369c1deb22 100644 (file)
@@ -15,12 +15,19 @@ package org.openhab.binding.mqtt.homeassistant.internal.component;
 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 class FanTests extends AbstractComponentTests {
         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 class FanTests extends AbstractComponentTests {
         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<String> getConfigTopics() {
         return Set.of(CONFIG_TOPIC);