[mqtt.homeassistant] add support for Number and Select components (#14230)

* [mqtt.homeassistant] add support for Select component

closes #13603

Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer 2023-01-21 07:02:49 -07:00 committed by GitHub
parent 89d0689e36
commit 00c10e1015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 382 additions and 0 deletions

View File

@ -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":

View File

@ -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<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();
}
}

View File

@ -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<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();
}
}

View File

@ -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<String> getConfigTopics() {
return Set.of(CONFIG_TOPIC);
}
}

View File

@ -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<String> getConfigTopics() {
return Set.of(CONFIG_TOPIC);
}
}