From f733c85343b932ed2a523f8ab63e24cbf3433549 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Fri, 6 Dec 2024 16:39:43 -0700 Subject: [PATCH] [mqtt.homeassistant] Implement Humidifier (#17853) * [mqtt.homeassistant] Implement Humidifier Signed-off-by: Cody Cutrer * remove debug logging from test Signed-off-by: Cody Cutrer --- .../README.md | 12 ++ .../internal/ComponentChannelType.java | 1 + .../internal/component/Climate.java | 4 +- .../internal/component/ComponentFactory.java | 2 + .../internal/component/Humidifier.java | 174 ++++++++++++++++++ .../resources/OH-INF/i18n/mqtt.properties | 1 + .../OH-INF/thing/homeassistant-channels.xml | 6 + .../internal/component/HumidifierTests.java | 130 +++++++++++++ 8 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Humidifier.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HumidifierTests.java diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/README.md b/bundles/org.openhab.binding.mqtt.homeassistant/README.md index 205fad3f4b5..b3e343f11d2 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/README.md +++ b/bundles/org.openhab.binding.mqtt.homeassistant/README.md @@ -109,6 +109,18 @@ If a device has multiple device triggers for the same subtype (the particular bu | direction | String | R/W | `forward` or `backward` | | json-attributes | String | RO | Additional attributes, as a serialized JSON string. | +### [Humidifier](https://www.home-assistant.io/integrations/humidifier.mqtt/) + +| Channel ID | Type | R/W | Description | +|------------------|----------------------|-----|------------------------------------------------------------------------------------------| +| state | Switch | R/W | If the humidifier should be on or off. | +| action | String | RO | What the humidifier is actively doing. One of `off`, `humidifying`, `drying`, or `idle`. | +| mode | String | R/W | Inspect the state description for valid values. | +| current-humidity | Number:Dimensionless | RO | The current detected relative humidity, in %. | +| target-humidity | Number:Dimensionless | R/W | The desired relative humidity, in %. | +| device-class | String | RO | `humidifier` or `dehumidifier` | +| json-attributes | String | RO | Additional attributes, as a serialized JSON string. | + ### [Light](https://www.home-assistant.io/integrations/light.mqtt/) | Channel ID | Type | R/W | Description | diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java index 4bb55035dff..38cad582330 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java @@ -32,6 +32,7 @@ public enum ComponentChannelType { STRING("ha-string"), SWITCH("ha-switch"), TRIGGER("ha-trigger"), + HUMIDITY("ha-humidity"), GPS_ACCURACY("ha-gps-accuracy"); final ChannelTypeUID channelTypeUID; diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java index a3c1572c809..ec92baf08cd 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java @@ -272,7 +272,7 @@ public class Climate extends AbstractComponent { channelConfiguration.awayModeCommandTopic, channelConfiguration.awayModeStateTemplate, channelConfiguration.awayModeStateTopic, commandFilter); - buildOptionalChannel(CURRENT_HUMIDITY_CH_ID, ComponentChannelType.NUMBER, + buildOptionalChannel(CURRENT_HUMIDITY_CH_ID, ComponentChannelType.HUMIDITY, new NumberValue(new BigDecimal(0), new BigDecimal(100), null, Units.PERCENT), updateListener, null, null, channelConfiguration.currentHumidityTemplate, channelConfiguration.currentHumidityTopic, null); @@ -310,7 +310,7 @@ public class Climate extends AbstractComponent { channelConfiguration.swingCommandTemplate, channelConfiguration.swingCommandTopic, channelConfiguration.swingStateTemplate, channelConfiguration.swingStateTopic, commandFilter); - buildOptionalChannel(TARGET_HUMIDITY_CH_ID, ComponentChannelType.NUMBER, + buildOptionalChannel(TARGET_HUMIDITY_CH_ID, ComponentChannelType.HUMIDITY, new NumberValue(channelConfiguration.minHumidity, channelConfiguration.maxHumidity, null, Units.PERCENT), updateListener, channelConfiguration.targetHumidityCommandTemplate, diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index 192d16e424e..c01fa30f815 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -71,6 +71,8 @@ public class ComponentFactory { return new Event(componentConfiguration, newStyleChannels); case "fan": return new Fan(componentConfiguration, newStyleChannels); + case "humidifier": + return new Humidifier(componentConfiguration, newStyleChannels); case "light": return Light.create(componentConfiguration, newStyleChannels); case "lock": diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Humidifier.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Humidifier.java new file mode 100644 index 00000000000..cedbbde975a --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Humidifier.java @@ -0,0 +1,174 @@ +/** + * 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.math.BigDecimal; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.values.NumberValue; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +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.binding.mqtt.homeassistant.internal.exception.ConfigurationException; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.Units; + +import com.google.gson.annotations.SerializedName; + +/** + * A MQTT Humidifier, following the https://www.home-assistant.io/integrations/humidifier.mqtt/ specification. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class Humidifier extends AbstractComponent { + public static final String ACTION_CHANNEL_ID = "action"; + public static final String CURRENT_HUMIDITY_CHANNEL_ID = "current-humidity"; + public static final String DEVICE_CLASS_CHANNEL_ID = "device-class"; + public static final String MODE_CHANNEL_ID = "mode"; + public static final String STATE_CHANNEL_ID = "state"; + public static final String TARGET_HUMIDITY_CHANNEL_ID = "target-humidity"; + + public static final String PLATFORM_HUMIDIFIER = "humidifier"; + public static final String[] ACTIONS = new String[] { "off", "humidifying", "drying", "idle" }; + + /** + * Configuration class for MQTT component + */ + static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Humidifier"); + } + + protected @Nullable Boolean optimistic; + + @SerializedName("action_topic") + protected @Nullable String actionTopic; + @SerializedName("action_template") + protected @Nullable String actionTemplate; + @SerializedName("command_topic") + protected String commandTopic = ""; + @SerializedName("command_template") + protected @Nullable String commandTemplate; + @SerializedName("state_topic") + protected @Nullable String stateTopic; + @SerializedName("state_value_template") + protected @Nullable String stateValueTemplate; + @SerializedName("current_humidity_topic") + protected @Nullable String currentHumidityTopic; + @SerializedName("current_humidity_template") + protected @Nullable String currentHumidityTemplate; + @SerializedName("target_humidity_command_topic") + protected @Nullable String targetHumidityCommandTopic; + @SerializedName("target_humidity_command_template") + protected @Nullable String targetHumidityCommandTemplate; + @SerializedName("target_humidity_state_topic") + protected @Nullable String targetHumidityStateTopic; + @SerializedName("target_humidity_state_template") + protected @Nullable String targetHumidityStateTemplate; + @SerializedName("mode_command_topic") + protected @Nullable String modeCommandTopic; + @SerializedName("mode_command_template") + protected @Nullable String modeCommandTemplate; + @SerializedName("mode_state_topic") + protected @Nullable String modeStateTopic; + @SerializedName("mode_state_template") + protected @Nullable String modeStateTemplate; + + @SerializedName("device_class") + protected @Nullable String deviceClass; + protected String platform = ""; + + @SerializedName("min_humidity") + protected BigDecimal minHumidity = BigDecimal.ZERO; + @SerializedName("max_humidity") + protected BigDecimal maxHumidity = new BigDecimal(100); + + @SerializedName("payload_on") + protected String payloadOn = "ON"; + @SerializedName("payload_off") + protected String payloadOff = "OFF"; + @SerializedName("payload_reset_humidity") + protected String payloadResetHumidity = "None"; + @SerializedName("payload_reset_mode") + protected String payloadResetMode = "None"; + protected @Nullable List modes; + } + + public Humidifier(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { + super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); + + if (!PLATFORM_HUMIDIFIER.equals(channelConfiguration.platform)) { + throw new ConfigurationException("platform must be " + PLATFORM_HUMIDIFIER); + } + + buildChannel(STATE_CHANNEL_ID, ComponentChannelType.SWITCH, + new OnOffValue(channelConfiguration.payloadOn, channelConfiguration.payloadOff), "State", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate) + .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.commandTemplate) + .inferOptimistic(channelConfiguration.optimistic).build(); + + buildChannel(TARGET_HUMIDITY_CHANNEL_ID, ComponentChannelType.HUMIDITY, + new NumberValue(channelConfiguration.minHumidity, channelConfiguration.maxHumidity, null, + Units.PERCENT), + "Target Humidity", componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.targetHumidityStateTopic, + channelConfiguration.targetHumidityStateTemplate) + .commandTopic(channelConfiguration.targetHumidityCommandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.targetHumidityCommandTemplate) + .inferOptimistic(channelConfiguration.optimistic).build(); + + if (channelConfiguration.actionTopic != null) { + buildChannel(ACTION_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(ACTIONS), "Action", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.actionTopic, channelConfiguration.actionTemplate).build(); + } + + if (channelConfiguration.modeCommandTopic != null) { + List modes = channelConfiguration.modes; + if (modes == null) { + throw new ConfigurationException("modes cannot be null if mode_command_topic is specified"); + } + TextValue modeValue = new TextValue(modes.toArray(new String[0])); + modeValue.setNullValue(channelConfiguration.payloadResetMode); + buildChannel(MODE_CHANNEL_ID, ComponentChannelType.STRING, modeValue, "Mode", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.modeStateTopic, channelConfiguration.modeStateTemplate) + .commandTopic(channelConfiguration.modeCommandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.modeCommandTemplate) + .inferOptimistic(channelConfiguration.optimistic).build(); + } + + if (channelConfiguration.currentHumidityTopic != null) { + buildChannel(CURRENT_HUMIDITY_CHANNEL_ID, ComponentChannelType.HUMIDITY, + new NumberValue(null, null, null, Units.PERCENT), "Current Humidity", + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.currentHumidityTopic, channelConfiguration.currentHumidityTemplate) + .build(); + } + + if (channelConfiguration.deviceClass != null) { + TextValue deviceClassValue = new TextValue(); + deviceClassValue.update(new StringType(channelConfiguration.deviceClass)); + buildChannel(DEVICE_CLASS_CHANNEL_ID, ComponentChannelType.STRING, deviceClassValue, "Device Class", + componentConfiguration.getUpdateListener()).build(); + } + + finalizeChannels(); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties index b4853d7c35c..774897379bd 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties @@ -18,6 +18,7 @@ channel-type.mqtt.ha-dimmer-advanced.label = Dimmer channel-type.mqtt.ha-dimmer.label = Dimmer channel-type.mqtt.ha-gps-accuracy.label = GPS Accuracy channel-type.mqtt.ha-gps-accuracy.description = The accuracy of the GPS fix, in meters. +channel-type.mqtt.ha-humidity.label = Humidity channel-type.mqtt.ha-image-advanced.label = Image channel-type.mqtt.ha-image.label = Image channel-type.mqtt.ha-location.label = Location diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml index 73320d90aa8..0a5bd650f7c 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml @@ -16,6 +16,12 @@ + + Number:Dimensionless + + + + Image diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HumidifierTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HumidifierTests.java new file mode 100644 index 00000000000..7942af65e17 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/HumidifierTests.java @@ -0,0 +1,130 @@ +/** + * 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.NumberValue; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.UnDefType; + +/** + * Tests for {@link Humidifier} + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class HumidifierTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "humidifier/bedroom_humidifier"; + + @SuppressWarnings("null") + @Test + public void test() { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "platform": "humidifier", + "name": "Bedroom humidifier", + "device_class": "humidifier", + "state_topic": "bedroom_humidifier/on/state", + "action_topic": "bedroom_humidifier/action", + "command_topic": "bedroom_humidifier/on/set", + "current_humidity_topic": "bedroom_humidifier/humidity/current", + "target_humidity_command_topic": "bedroom_humidifier/humidity/set", + "target_humidity_state_topic": "bedroom_humidifier/humidity/state", + "mode_state_topic": "bedroom_humidifier/mode/state", + "mode_command_topic": "bedroom_humidifier/preset/preset_mode", + "modes": [ + "normal", + "eco", + "away", + "boost", + "comfort", + "home", + "sleep", + "auto", + "baby"], + "qos": 0, + "payload_on": "true", + "payload_off": "false", + "min_humidity": 30, + "max_humidity": 80 + } + """); + + assertThat(component.channels.size(), is(6)); + assertThat(component.getName(), is("Bedroom humidifier")); + + assertChannel(component, Humidifier.STATE_CHANNEL_ID, "bedroom_humidifier/on/state", + "bedroom_humidifier/on/set", "State", OnOffValue.class); + assertChannel(component, Humidifier.ACTION_CHANNEL_ID, "bedroom_humidifier/action", "", "Action", + TextValue.class); + assertChannel(component, Humidifier.MODE_CHANNEL_ID, "bedroom_humidifier/mode/state", + "bedroom_humidifier/preset/preset_mode", "Mode", TextValue.class); + assertChannel(component, Humidifier.DEVICE_CLASS_CHANNEL_ID, "", "", "Device Class", TextValue.class); + assertChannel(component, Humidifier.CURRENT_HUMIDITY_CHANNEL_ID, "bedroom_humidifier/humidity/current", "", + "Current Humidity", NumberValue.class); + assertChannel(component, Humidifier.TARGET_HUMIDITY_CHANNEL_ID, "bedroom_humidifier/humidity/state", + "bedroom_humidifier/humidity/set", "Target Humidity", NumberValue.class); + + publishMessage("bedroom_humidifier/on/state", "true"); + assertState(component, Humidifier.STATE_CHANNEL_ID, OnOffType.ON); + publishMessage("bedroom_humidifier/on/state", "false"); + assertState(component, Humidifier.STATE_CHANNEL_ID, OnOffType.OFF); + + publishMessage("bedroom_humidifier/action", "off"); + assertState(component, Humidifier.ACTION_CHANNEL_ID, new StringType("off")); + publishMessage("bedroom_humidifier/action", "idle"); + assertState(component, Humidifier.ACTION_CHANNEL_ID, new StringType("idle")); + publishMessage("bedroom_humidifier/action", "invalid"); + assertState(component, Humidifier.ACTION_CHANNEL_ID, new StringType("idle")); + + publishMessage("bedroom_humidifier/mode/state", "eco"); + assertState(component, Humidifier.MODE_CHANNEL_ID, new StringType("eco")); + publishMessage("bedroom_humidifier/mode/state", "invalid"); + assertState(component, Humidifier.MODE_CHANNEL_ID, new StringType("eco")); + publishMessage("bedroom_humidifier/mode/state", "None"); + assertState(component, Humidifier.MODE_CHANNEL_ID, UnDefType.NULL); + + publishMessage("bedroom_humidifier/humidity/current", "35"); + assertState(component, Humidifier.CURRENT_HUMIDITY_CHANNEL_ID, new QuantityType<>(35, Units.PERCENT)); + publishMessage("bedroom_humidifier/humidity/state", "40"); + assertState(component, Humidifier.TARGET_HUMIDITY_CHANNEL_ID, new QuantityType<>(40, Units.PERCENT)); + + component.getChannel(Humidifier.STATE_CHANNEL_ID).getState().publishValue(OnOffType.OFF); + assertPublished("bedroom_humidifier/on/set", "false"); + component.getChannel(Humidifier.STATE_CHANNEL_ID).getState().publishValue(OnOffType.ON); + assertPublished("bedroom_humidifier/on/set", "true"); + + component.getChannel(Humidifier.MODE_CHANNEL_ID).getState().publishValue(new StringType("eco")); + assertPublished("bedroom_humidifier/preset/preset_mode", "eco"); + + component.getChannel(Humidifier.TARGET_HUMIDITY_CHANNEL_ID).getState().publishValue(new DecimalType(45)); + assertPublished("bedroom_humidifier/humidity/set", "45"); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}