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":
--- /dev/null
+/**
+ * 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<Number.ChannelConfiguration> {
+ 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();
+ }
+}
--- /dev/null
+/**
+ * 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<Select.ChannelConfiguration> {
+ 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();
+ }
+}
--- /dev/null
+/**
+ * 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<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}
--- /dev/null
+/**
+ * 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<String> getConfigTopics() {
+ return Set.of(CONFIG_TOPIC);
+ }
+}