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 fbe97dd6c..f502f45fd 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 @@ -69,6 +69,10 @@ public class ComponentFactory { return Light.create(componentConfiguration); case "lock": return new Lock(componentConfiguration); + case "number": + return new Number(componentConfiguration); + case "select": + return new Select(componentConfiguration); case "sensor": return new Sensor(componentConfiguration); case "switch": diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java new file mode 100644 index 000000000..a012796c7 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2010-2023 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.values.NumberValue; +import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; +import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; +import org.openhab.core.types.util.UnitUtils; + +import com.google.gson.annotations.SerializedName; + +/** + * A MQTT Number, following the https://www.home-assistant.io/components/number.mqtt/ specification. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class Number extends AbstractComponent { + public static final String NUMBER_CHANNEL_ID = "number"; // Randomly chosen channel "ID" + + /** + * Configuration class for MQTT component + */ + static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Number"); + } + + protected @Nullable Boolean optimistic; + + @SerializedName("unit_of_measurement") + protected @Nullable String unitOfMeasurement; + @SerializedName("device_class") + protected @Nullable String deviceClass; + + @SerializedName("command_template") + protected @Nullable String commandTemplate; + @SerializedName("command_topic") + protected @Nullable String commandTopic; + @SerializedName("state_topic") + protected String stateTopic = ""; + + protected BigDecimal min = new BigDecimal(1); + protected BigDecimal max = new BigDecimal(100); + protected BigDecimal step = new BigDecimal(1); + + @SerializedName("payload_reset") + protected String payloadReset = "None"; + + protected String mode = "auto"; + + @SerializedName("json_attributes_topic") + protected @Nullable String jsonAttributesTopic; + @SerializedName("json_attributes_template") + protected @Nullable String jsonAttributesTemplate; + } + + public Number(ComponentFactory.ComponentConfiguration componentConfiguration) { + super(componentConfiguration, ChannelConfiguration.class); + + boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic + : channelConfiguration.stateTopic.isBlank(); + + if (optimistic && !channelConfiguration.stateTopic.isBlank()) { + throw new ConfigurationException("Component:Number does not support forced optimistic mode"); + } + + NumberValue value = new NumberValue(channelConfiguration.min, channelConfiguration.max, + channelConfiguration.step, UnitUtils.parseUnit(channelConfiguration.unitOfMeasurement)); + + buildChannel(NUMBER_CHANNEL_ID, value, channelConfiguration.getName(), + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) + .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.commandTemplate) + .build(); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java new file mode 100644 index 000000000..174278977 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2010-2023 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 org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; +import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; + +import com.google.gson.annotations.SerializedName; + +/** + * A MQTT select, following the https://www.home-assistant.io/components/select.mqtt/ specification. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class Select extends AbstractComponent { + public static final String SELECT_CHANNEL_ID = "select"; // Randomly chosen channel "ID" + + /** + * Configuration class for MQTT component + */ + static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Select"); + } + + protected @Nullable Boolean optimistic; + + @SerializedName("command_template") + protected @Nullable String commandTemplate; + @SerializedName("command_topic") + protected @Nullable String commandTopic; + @SerializedName("state_topic") + protected String stateTopic = ""; + + protected String[] options = new String[0]; + + @SerializedName("json_attributes_topic") + protected @Nullable String jsonAttributesTopic; + @SerializedName("json_attributes_template") + protected @Nullable String jsonAttributesTemplate; + } + + public Select(ComponentFactory.ComponentConfiguration componentConfiguration) { + super(componentConfiguration, ChannelConfiguration.class); + + boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic + : channelConfiguration.stateTopic.isBlank(); + + if (optimistic && !channelConfiguration.stateTopic.isBlank()) { + throw new ConfigurationException("Component:Select does not support forced optimistic mode"); + } + + TextValue value = new TextValue(channelConfiguration.options); + + buildChannel(SELECT_CHANNEL_ID, value, channelConfiguration.getName(), + componentConfiguration.getUpdateListener()) + .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) + .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos(), channelConfiguration.commandTemplate) + .build(); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java new file mode 100644 index 000000000..4704f87f7 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2010-2023 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.core.library.types.DecimalType; + +/** + * Tests for {@link Number} + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class NumberTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "number/0x0000000000000000_number_zigbee2mqtt"; + + @SuppressWarnings("null") + @Test + public void test() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "name": "BWA Link Hot Tub Pump 1", + "availability_topic": "homie/bwa/$state", + "payload_available": "ready", + "payload_not_available": "lost", + "qos": 1, + "icon": "mdi:chart-bubble", + "device": { + "manufacturer": "Balboa Water Group", + "sw_version": "2.1.3", + "model": "BFBP20", + "name": "BWA Link", + "identifiers": "bwa" + }, + "state_topic": "homie/bwa/spa/pump1", + "command_topic": "homie/bwa/spa/pump1/set", + "command_template": "{{ value | round(0) }}", + "min": 0, + "max": 2, + "unique_id": "bwa_spa_pump1" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("BWA Link Hot Tub Pump 1")); + + assertChannel(component, Number.NUMBER_CHANNEL_ID, "homie/bwa/spa/pump1", "homie/bwa/spa/pump1/set", + "BWA Link Hot Tub Pump 1", NumberValue.class); + + publishMessage("homie/bwa/spa/pump1", "1"); + assertState(component, Number.NUMBER_CHANNEL_ID, new DecimalType(1)); + publishMessage("homie/bwa/spa/pump1", "2"); + assertState(component, Number.NUMBER_CHANNEL_ID, new DecimalType(2)); + + component.getChannel(Number.NUMBER_CHANNEL_ID).getState().publishValue(new DecimalType(1.1)); + assertPublished("homie/bwa/spa/pump1/set", "1"); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SelectTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SelectTests.java new file mode 100644 index 000000000..64c6e2302 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SelectTests.java @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2010-2023 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 static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.core.library.types.StringType; + +/** + * Tests for {@link Select} + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class SelectTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "select/0x54ef44100064b266"; + + @SuppressWarnings("null") + @Test + public void testSelectWithStateAndCommand() { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "availability": [ + {"topic": "zigbee2mqtt/bridge/state"}, + {"topic": "zigbee2mqtt/gbos/availability"} + ], + "availability_mode": "all", + "command_topic": "zigbee2mqtt/gbos/set/approach_distance", + "device": { + "configuration_url": "#/device/0x54ef44100064b266/info", + "identifiers": [ + "zigbee2mqtt_0x54ef44100064b266" + ], + "manufacturer": "Xiaomi", + "model": "Aqara presence detector FP1 (RTCZCGQ11LM)", + "name": "Guest Bathroom Occupancy Sensor", + "sw_version": "" + }, + "name": "Guest Bathroom Occupancy Sensor approach distance", + "options": [ + "far", + "medium", + "near" + ], + "state_topic": "zigbee2mqtt/gbos", + "unique_id": "0x54ef44100064b266_approach_distance_zigbee2mqtt", + "value_template":"{{ value_json.approach_distance }}" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("Guest Bathroom Occupancy Sensor approach distance")); + + assertChannel(component, Select.SELECT_CHANNEL_ID, "zigbee2mqtt/gbos", "zigbee2mqtt/gbos/set/approach_distance", + "Guest Bathroom Occupancy Sensor approach distance", TextValue.class); + + publishMessage("zigbee2mqtt/gbos", "{\"approach_distance\": \"far\"}"); + assertState(component, Select.SELECT_CHANNEL_ID, new StringType("far")); + publishMessage("zigbee2mqtt/gbos", "{\"approach_distance\": \"medium\"}"); + assertState(component, Select.SELECT_CHANNEL_ID, new StringType("medium")); + + component.getChannel(Select.SELECT_CHANNEL_ID).getState().publishValue(new StringType("near")); + assertPublished("zigbee2mqtt/gbos/set/approach_distance", "near"); + component.getChannel(Select.SELECT_CHANNEL_ID).getState().publishValue(new StringType("medium")); + assertPublished("zigbee2mqtt/gbos/set/approach_distance", "medium"); + assertThrows(IllegalArgumentException.class, + () -> component.getChannel(Select.SELECT_CHANNEL_ID).getState().publishValue(new StringType("bogus"))); + assertNotPublished("zigbee2mqtt/gbos/set/approach_distance", "bogus"); + } + + @SuppressWarnings("null") + @Test + public void testSelectWithCommandTemplate() { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "availability": [ + {"topic": "zigbee2mqtt/bridge/state"}, + {"topic": "zigbee2mqtt/gbos/availability"} + ], + "availability_mode": "all", + "command_topic": "zigbee2mqtt/gbos/set/approach_distance", + "command_template": "set to {{ value }}", + "device": { + "configuration_url": "#/device/0x54ef44100064b266/info", + "identifiers": [ + "zigbee2mqtt_0x54ef44100064b266" + ], + "manufacturer": "Xiaomi", + "model": "Aqara presence detector FP1 (RTCZCGQ11LM)", + "name": "Guest Bathroom Occupancy Sensor", + "sw_version": "" + }, + "name": "Guest Bathroom Occupancy Sensor approach distance", + "options": [ + "far", + "medium", + "near" + ], + "state_topic": "zigbee2mqtt/gbos", + "unique_id": "0x54ef44100064b266_approach_distance_zigbee2mqtt", + "value_template":"{{ value_json.approach_distance }}" + } + """); + + component.getChannel(Select.SELECT_CHANNEL_ID).getState().publishValue(new StringType("near")); + assertPublished("zigbee2mqtt/gbos/set/approach_distance", "set to near"); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}