[mqtt.homeassistant] Use a single channel for all events from a single button (#17598)

Use the `subtype` field to collapse multiple DeviceAutomation components into
a single channel.

Signed-off-by: Cody Cutrer <cody@cutrer.us>
This commit is contained in:
Cody Cutrer 2024-10-21 14:24:56 -05:00 committed by GitHub
parent 8d9ee16e42
commit 722818c30a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 350 additions and 55 deletions

View File

@ -85,8 +85,12 @@ public class ComponentChannel {
channelState.setChannelUID(channelUID); channelState.setChannelUID(channelUID);
} }
public void resetConfiguration(Configuration configuration) {
channel = ChannelBuilder.create(channel).withConfiguration(configuration).build();
}
public void clearConfiguration() { public void clearConfiguration() {
channel = ChannelBuilder.create(channel).withConfiguration(new Configuration()).build(); resetConfiguration(new Configuration());
} }
public ChannelState getState() { public ChannelState getState() {
@ -142,6 +146,8 @@ public class ComponentChannel {
private @Nullable String templateIn; private @Nullable String templateIn;
private @Nullable String templateOut; private @Nullable String templateOut;
private @Nullable Configuration configuration;
private String format = "%s"; private String format = "%s";
public Builder(AbstractComponent<?> component, String channelID, ChannelTypeUID channelTypeUID, public Builder(AbstractComponent<?> component, String channelID, ChannelTypeUID channelTypeUID,
@ -225,6 +231,11 @@ public class ComponentChannel {
return this; return this;
} }
public Builder withConfiguration(Configuration configuration) {
this.configuration = configuration;
return this;
}
// If the component explicitly specifies optimistic, or it's missing a state topic // If the component explicitly specifies optimistic, or it's missing a state topic
// put it in optimistic mode (which, in openHAB parlance, means to auto-update the // put it in optimistic mode (which, in openHAB parlance, means to auto-update the
// item). // item).
@ -286,9 +297,12 @@ public class ComponentChannel {
commandDescription = valueState.createCommandDescription().build(); commandDescription = valueState.createCommandDescription().build();
} }
Configuration configuration = new Configuration(); Configuration configuration = this.configuration;
if (configuration == null) {
configuration = new Configuration();
configuration.put("config", component.getChannelConfigurationJson()); configuration.put("config", component.getChannelConfigurationJson());
component.getHaID().toConfig(configuration); component.getHaID().toConfig(configuration);
}
channel = ChannelBuilder.create(channelUID, channelState.getItemType()).withType(channelTypeUID) channel = ChannelBuilder.create(channelUID, channelState.getItemType()).withType(channelTypeUID)
.withKind(kind).withLabel(label).withConfiguration(configuration) .withKind(kind).withLabel(label).withConfiguration(configuration)

View File

@ -91,11 +91,11 @@ public class HaID {
private static String createTopic(HaID id) { private static String createTopic(HaID id) {
StringBuilder str = new StringBuilder(); StringBuilder str = new StringBuilder();
str.append(id.baseTopic).append('/').append(id.component).append('/'); str.append(id.component).append('/');
if (!id.nodeID.isBlank()) { if (!id.nodeID.isBlank()) {
str.append(id.nodeID).append('/'); str.append(id.nodeID).append('/');
} }
str.append(id.objectID).append('/'); str.append(id.objectID);
return str.toString(); return str.toString();
} }
@ -232,7 +232,7 @@ public class HaID {
* @return fallback group id * @return fallback group id
*/ */
public String getTopic(String suffix) { public String getTopic(String suffix) {
return topic + suffix; return baseTopic + "/" + topic + "/" + suffix;
} }
@Override @Override

View File

@ -12,12 +12,18 @@
*/ */
package org.openhab.binding.mqtt.homeassistant.internal.component; package org.openhab.binding.mqtt.homeassistant.internal.component;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.mqtt.generic.values.TextValue; 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.ComponentChannelType;
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
import org.openhab.core.config.core.Configuration;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
@ -31,7 +37,7 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
/** /**
* Configuration class for MQTT component * Configuration class for MQTT component
*/ */
static class ChannelConfiguration extends AbstractChannelConfiguration { public static class ChannelConfiguration extends AbstractChannelConfiguration {
ChannelConfiguration() { ChannelConfiguration() {
super("MQTT Device Trigger"); super("MQTT Device Trigger");
} }
@ -43,6 +49,14 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
protected String subtype = ""; protected String subtype = "";
protected @Nullable String payload; protected @Nullable String payload;
public String getTopic() {
return topic;
}
public String getSubtype() {
return subtype;
}
} }
public DeviceTrigger(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { public DeviceTrigger(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) {
@ -58,6 +72,14 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
throw new ConfigurationException("Component:DeviceTrigger must have a subtype"); throw new ConfigurationException("Component:DeviceTrigger must have a subtype");
} }
if (newStyleChannels) {
// Name the channel after the subtype, not the component ID
// So that we only end up with a single channel for all possible events
// for a single button (subtype is the button, type is type of press)
componentId = channelConfiguration.subtype;
groupId = null;
}
TextValue value; TextValue value;
String payload = channelConfiguration.payload; String payload = channelConfiguration.payload;
if (payload != null) { if (payload != null) {
@ -69,6 +91,54 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
buildChannel(channelConfiguration.type, ComponentChannelType.TRIGGER, value, getName(), buildChannel(channelConfiguration.type, ComponentChannelType.TRIGGER, value, getName(),
componentConfiguration.getUpdateListener()) componentConfiguration.getUpdateListener())
.stateTopic(channelConfiguration.topic, channelConfiguration.getValueTemplate()).trigger(true).build(); .stateTopic(channelConfiguration.topic, channelConfiguration.getValueTemplate()).trigger(true).build();
finalizeChannels(); }
/**
* Take another DeviceTrigger (presumably whose subtype, topic, and value template match),
* and adjust this component's channel to accept the payload that trigger allows.
*
* @return if the component was stopped, and thus needs restarted
*/
public boolean merge(DeviceTrigger other) {
ComponentChannel channel = channels.get(componentId);
TextValue value = (TextValue) channel.getState().getCache();
Set<String> payloads = value.getStates();
// Append objectid/config to channel configuration
Configuration currentConfiguration = channel.getChannel().getConfiguration();
Configuration newConfiguration = new Configuration();
newConfiguration.put("component", currentConfiguration.get("component"));
newConfiguration.put("nodeid", currentConfiguration.get("nodeid"));
Object objectIdObject = currentConfiguration.get("objectid");
if (objectIdObject instanceof String objectIdString) {
newConfiguration.put("objectid", List.of(objectIdString, other.getHaID().objectID));
} else if (objectIdObject instanceof List<?> objectIdList) {
newConfiguration.put("objectid",
Stream.concat(objectIdList.stream(), Stream.of(other.getHaID().objectID)).toList());
}
Object configObject = currentConfiguration.get("config");
if (configObject instanceof String configString) {
newConfiguration.put("config", List.of(configString, other.getChannelConfigurationJson()));
} else if (configObject instanceof List<?> configList) {
newConfiguration.put("config",
Stream.concat(configList.stream(), Stream.of(other.getChannelConfigurationJson())).toList());
}
// Append payload to allowed values
String otherPayload = other.getChannelConfiguration().payload;
if (payloads == null || otherPayload == null) {
// Need to accept anything
value = new TextValue();
} else {
String[] newValues = Stream.concat(payloads.stream(), Stream.of(otherPayload)).toArray(String[]::new);
value = new TextValue(newValues);
}
// Recreate the channel
stop();
buildChannel(channelConfiguration.type, ComponentChannelType.TRIGGER, value, componentId,
componentConfiguration.getUpdateListener()).withConfiguration(newConfiguration)
.stateTopic(channelConfiguration.topic, channelConfiguration.getValueTemplate()).trigger(true).build();
return true;
} }
} }

View File

@ -13,9 +13,11 @@
package org.openhab.binding.mqtt.homeassistant.internal.handler; package org.openhab.binding.mqtt.homeassistant.internal.handler;
import java.net.URI; import java.net.URI;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
@ -39,6 +41,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.HaID;
import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent; import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent;
import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory; import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory;
import org.openhab.binding.mqtt.homeassistant.internal.component.DeviceTrigger;
import org.openhab.binding.mqtt.homeassistant.internal.component.Update; import org.openhab.binding.mqtt.homeassistant.internal.component.Update;
import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory; import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
@ -156,19 +159,22 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
continue; continue;
} }
} }
Configuration channelConfig = channel.getConfiguration(); Configuration multiComponentChannelConfig = channel.getConfiguration();
if (!channelConfig.containsKey("component") if (!multiComponentChannelConfig.containsKey("component")
|| !channelConfig.containsKey("objectid") | !channelConfig.containsKey("config")) { || !multiComponentChannelConfig.containsKey("objectid")
|| !multiComponentChannelConfig.containsKey("config")) {
// Must be a secondary channel // Must be a secondary channel
continue; continue;
} }
List<Configuration> flattenedConfig = flattenChannelConfiguration(multiComponentChannelConfig,
channel.getUID());
for (Configuration channelConfig : flattenedConfig) {
HaID haID = HaID.fromConfig(config.basetopic, channelConfig); HaID haID = HaID.fromConfig(config.basetopic, channelConfig);
if (!config.topics.contains(haID.getTopic())) { if (!config.topics.contains(haID.getTopic())) {
// don't add a component for this channel that isn't configured on the thing // don't add a component for this channel that isn't configured on the thing
// anymore // anymore. It will disappear from the thing when the thing type is updated below
// It will disappear from the thing when the thing type is updated below
continue; continue;
} }
@ -187,6 +193,7 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
logger.warn("Cannot restore component {}: {}", thing, e.getMessage()); logger.warn("Cannot restore component {}: {}", thing, e.getMessage());
} }
} }
}
if (updateThingType(typeID)) { if (updateThingType(typeID)) {
super.initialize(); super.initialize();
} }
@ -280,7 +287,8 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
AbstractComponent<?> known = haComponentsByUniqueId.get(discovered.getUniqueId()); AbstractComponent<?> known = haComponentsByUniqueId.get(discovered.getUniqueId());
// Is component already known? // Is component already known?
if (known != null) { if (known != null) {
if (discovered.getConfigHash() != known.getConfigHash()) { if (discovered.getConfigHash() != known.getConfigHash()
&& discovered.getUniqueId().equals(known.getUniqueId())) {
// Don't wait for the future to complete. We are also not interested in failures. // Don't wait for the future to complete. We are also not interested in failures.
// The component will be replaced in a moment. // The component will be replaced in a moment.
known.stop(); known.stop();
@ -422,6 +430,35 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
private void addComponent(AbstractComponent component) { private void addComponent(AbstractComponent component) {
AbstractComponent existing = haComponents.get(component.getComponentId()); AbstractComponent existing = haComponents.get(component.getComponentId());
if (existing != null) { if (existing != null) {
// DeviceTriggers that are for the same subtype, topic, and value template
// can be coalesced together
if (component instanceof DeviceTrigger newTrigger && existing instanceof DeviceTrigger oldTrigger
&& newTrigger.getChannelConfiguration().getSubtype()
.equals(oldTrigger.getChannelConfiguration().getSubtype())
&& newTrigger.getChannelConfiguration().getTopic()
.equals(oldTrigger.getChannelConfiguration().getTopic())
&& oldTrigger.getHaID().nodeID.equals(newTrigger.getHaID().nodeID)) {
String newTriggerValueTemplate = newTrigger.getChannelConfiguration().getValueTemplate();
String oldTriggerValueTemplate = oldTrigger.getChannelConfiguration().getValueTemplate();
if ((newTriggerValueTemplate == null && oldTriggerValueTemplate == null)
|| (newTriggerValueTemplate != null & oldTriggerValueTemplate != null
&& newTriggerValueTemplate.equals(oldTriggerValueTemplate))) {
// Adjust the set of valid values
MqttBrokerConnection connection = this.connection;
if (oldTrigger.merge(newTrigger) && connection != null) {
// Make sure to re-start if this did something, and it was stopped
oldTrigger.start(connection, scheduler, 0).exceptionally(e -> {
logger.warn("Failed to start component {}", oldTrigger.getHaID(), e);
return null;
});
}
haComponentsByUniqueId.put(component.getUniqueId(), component);
System.out.println("don't forget to add to the channel config");
return;
}
}
// rename the conflict // rename the conflict
haComponents.remove(existing.getComponentId()); haComponents.remove(existing.getComponentId());
existing.resolveConflict(); existing.resolveConflict();
@ -431,4 +468,41 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
haComponents.put(component.getComponentId(), component); haComponents.put(component.getComponentId(), component);
haComponentsByUniqueId.put(component.getUniqueId(), component); haComponentsByUniqueId.put(component.getUniqueId(), component);
} }
/**
* Takes a Configuration where objectid and config are a list, and generates
* multiple Configurations where there are single objects
*/
private List<Configuration> flattenChannelConfiguration(Configuration multiComponentChannelConfig,
ChannelUID channelUID) {
Object component = multiComponentChannelConfig.get("component");
Object nodeid = multiComponentChannelConfig.get("nodeid");
if ((multiComponentChannelConfig.get("objectid") instanceof List objectIds)
&& (multiComponentChannelConfig.get("config") instanceof List configurations)) {
if (objectIds.size() != configurations.size()) {
logger.warn("objectid and config for channel {} do not have the same number of items; ignoring",
channelUID);
return List.of();
}
List<Configuration> result = new ArrayList();
Iterator<Object> objectIdIterator = objectIds.iterator();
Iterator<Object> configIterator = configurations.iterator();
while (objectIdIterator.hasNext()) {
Configuration componentConfiguration = new Configuration();
componentConfiguration.put("component", component);
componentConfiguration.put("nodeid", nodeid);
componentConfiguration.put("objectid", objectIdIterator.next());
componentConfiguration.put("config", configIterator.next());
result.add(componentConfiguration);
}
return result;
} else {
return List.of(multiComponentChannelConfig);
}
}
// For testing
Map<@Nullable String, AbstractComponent<?>> getComponents() {
return haComponents;
}
} }

View File

@ -15,12 +15,12 @@
<description>Optional node name of the component</description> <description>Optional node name of the component</description>
<default></default> <default></default>
</parameter> </parameter>
<parameter name="objectid" type="text" readOnly="true"> <parameter name="objectid" type="text" readOnly="true" multiple="true">
<label>Object ID</label> <label>Object ID</label>
<description>Object ID of the component</description> <description>Object ID of the component</description>
<default></default> <default></default>
</parameter> </parameter>
<parameter name="config" type="text" readOnly="true"> <parameter name="config" type="text" readOnly="true" multiple="true">
<label>JSON Configuration</label> <label>JSON Configuration</label>
<description>The JSON configuration string received by the component via MQTT.</description> <description>The JSON configuration string received by the component via MQTT.</description>
<default></default> <default></default>

View File

@ -94,7 +94,7 @@ public abstract class AbstractHomeAssistantTests extends JavaTest {
protected final Bridge bridgeThing = BridgeBuilder.create(BRIDGE_TYPE_UID, BRIDGE_UID).build(); protected final Bridge bridgeThing = BridgeBuilder.create(BRIDGE_TYPE_UID, BRIDGE_UID).build();
protected final BrokerHandler bridgeHandler = spy(new BrokerHandler(bridgeThing)); protected final BrokerHandler bridgeHandler = spy(new BrokerHandler(bridgeThing));
protected final Thing haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).build(); protected Thing haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).build();
protected final ConcurrentMap<String, Set<MqttMessageSubscriber>> subscriptions = new ConcurrentHashMap<>(); protected final ConcurrentMap<String, Set<MqttMessageSubscriber>> subscriptions = new ConcurrentHashMap<>();
private @Mock @NonNullByDefault({}) TransformationService transformationService1Mock; private @Mock @NonNullByDefault({}) TransformationService transformationService1Mock;

View File

@ -36,6 +36,7 @@ public class HaIDTests {
assertThat(subject.objectID, is("name")); assertThat(subject.objectID, is("name"));
assertThat(subject.component, is("switch")); assertThat(subject.component, is("switch"));
assertThat(subject.getTopic(), is("switch/name"));
assertThat(subject.getTopic("suffix"), is("homeassistant/switch/name/suffix")); assertThat(subject.getTopic("suffix"), is("homeassistant/switch/name/suffix"));
Configuration config = new Configuration(); Configuration config = new Configuration();
@ -58,6 +59,7 @@ public class HaIDTests {
assertThat(subject.objectID, is("name")); assertThat(subject.objectID, is("name"));
assertThat(subject.component, is("switch")); assertThat(subject.component, is("switch"));
assertThat(subject.getTopic(), is("switch/node/name"));
assertThat(subject.getTopic("suffix"), is("homeassistant/switch/node/name/suffix")); assertThat(subject.getTopic("suffix"), is("homeassistant/switch/node/name/suffix"));
Configuration config = new Configuration(); Configuration config = new Configuration();

View File

@ -80,6 +80,9 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing); when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
if (useNewStyleChannels()) {
haThing.setProperty("newStyleChannels", "true");
}
thingHandler = new LatchThingHandler(haThing, channelTypeProvider, stateDescriptionProvider, thingHandler = new LatchThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
channelTypeRegistry, SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT); channelTypeRegistry, SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
thingHandler.setConnection(bridgeConnection); thingHandler.setConnection(bridgeConnection);
@ -104,6 +107,13 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
*/ */
protected abstract Set<String> getConfigTopics(); protected abstract Set<String> getConfigTopics();
/**
* If new style channels should be used for this test.
*/
protected boolean useNewStyleChannels() {
return false;
}
/** /**
* Process payload to discover and configure component. Topic should be added to {@link #getConfigTopics()} * Process payload to discover and configure component. Topic should be added to {@link #getConfigTopics()}
* *

View File

@ -14,12 +14,18 @@ package org.openhab.binding.mqtt.homeassistant.internal.component;
import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.openhab.binding.mqtt.generic.values.TextValue; import org.openhab.binding.mqtt.generic.values.TextValue;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
import org.openhab.core.config.core.Configuration;
/** /**
* Tests for {@link DeviceTrigger} * Tests for {@link DeviceTrigger}
@ -28,12 +34,13 @@ import org.openhab.binding.mqtt.generic.values.TextValue;
*/ */
@NonNullByDefault @NonNullByDefault
public class DeviceTriggerTests extends AbstractComponentTests { public class DeviceTriggerTests extends AbstractComponentTests {
public static final String CONFIG_TOPIC = "device_automation/0x8cf681fffe2fd2a6"; public static final String CONFIG_TOPIC_1 = "device_automation/0x8cf681fffe2fd2a6/press";
public static final String CONFIG_TOPIC_2 = "device_automation/0x8cf681fffe2fd2a6/release";
@SuppressWarnings("null") @SuppressWarnings("null")
@Test @Test
public void test() throws InterruptedException { public void test() throws InterruptedException {
var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC_1), """
{ {
"automation_type": "trigger", "automation_type": "trigger",
"device": { "device": {
@ -56,19 +63,93 @@ public class DeviceTriggerTests extends AbstractComponentTests {
assertThat(component.channels.size(), is(1)); assertThat(component.channels.size(), is(1));
assertThat(component.getName(), is("MQTT Device Trigger")); assertThat(component.getName(), is("MQTT Device Trigger"));
assertChannel(component, "action", "zigbee2mqtt/Charge Now Button/action", "", "MQTT Device Trigger", assertChannel(component, "on", "zigbee2mqtt/Charge Now Button/action", "", "MQTT Device Trigger",
TextValue.class); TextValue.class);
spyOnChannelUpdates(component, "action"); spyOnChannelUpdates(component, "on");
publishMessage("zigbee2mqtt/Charge Now Button/action", "on"); publishMessage("zigbee2mqtt/Charge Now Button/action", "on");
assertTriggered(component, "action", "on"); assertTriggered(component, "on", "on");
publishMessage("zigbee2mqtt/Charge Now Button/action", "off"); publishMessage("zigbee2mqtt/Charge Now Button/action", "off");
assertNotTriggered(component, "action", "off"); assertNotTriggered(component, "on", "off");
}
@SuppressWarnings("null")
@Test
public void testMerge() throws InterruptedException {
var component1 = (DeviceTrigger) discoverComponent(configTopicToMqtt(CONFIG_TOPIC_1), """
{
"automation_type": "trigger",
"device": {
"configuration_url": "#/device/0x8cf681fffe2fd2a6/info",
"identifiers": [
"zigbee2mqtt_0x8cf681fffe2fd2a6"
],
"manufacturer": "IKEA",
"model": "TRADFRI shortcut button (E1812)",
"name": "Charge Now Button",
"sw_version": "2.3.015"
},
"payload": "press",
"subtype": "turn_on",
"topic": "zigbee2mqtt/Charge Now Button/action",
"type": "button_long_press"
}
""");
var component2 = (DeviceTrigger) discoverComponent(configTopicToMqtt(CONFIG_TOPIC_2), """
{
"automation_type": "trigger",
"device": {
"configuration_url": "#/device/0x8cf681fffe2fd2a6/info",
"identifiers": [
"zigbee2mqtt_0x8cf681fffe2fd2a6"
],
"manufacturer": "IKEA",
"model": "TRADFRI shortcut button (E1812)",
"name": "Charge Now Button",
"sw_version": "2.3.015"
},
"payload": "release",
"subtype": "turn_on",
"topic": "zigbee2mqtt/Charge Now Button/action",
"type": "button_long_release"
}
""");
assertThat(component1.channels.size(), is(1));
ComponentChannel channel = Objects.requireNonNull(component1.getChannel("turn_on"));
TextValue value = (TextValue) channel.getState().getCache();
Set<String> payloads = value.getStates();
assertNotNull(payloads);
assertThat(payloads.size(), is(2));
assertThat(payloads.contains("press"), is(true));
assertThat(payloads.contains("release"), is(true));
Configuration channelConfig = channel.getChannel().getConfiguration();
Object config = channelConfig.get("config");
assertNotNull(config);
assertThat(config.getClass(), is(ArrayList.class));
List<?> configList = (List<?>) config;
assertThat(configList.size(), is(2));
spyOnChannelUpdates(component1, "turn_on");
publishMessage("zigbee2mqtt/Charge Now Button/action", "press");
assertTriggered(component1, "turn_on", "press");
publishMessage("zigbee2mqtt/Charge Now Button/action", "release");
assertTriggered(component1, "turn_on", "release");
publishMessage("zigbee2mqtt/Charge Now Button/action", "otherwise");
assertNotTriggered(component1, "turn_on", "otherwise");
} }
@Override @Override
protected Set<String> getConfigTopics() { protected Set<String> getConfigTopics() {
return Set.of(CONFIG_TOPIC); return Set.of(CONFIG_TOPIC_1, CONFIG_TOPIC_2);
}
@Override
protected boolean useNewStyleChannels() {
return true;
} }
} }

View File

@ -12,6 +12,8 @@
*/ */
package org.openhab.binding.mqtt.homeassistant.internal.handler; package org.openhab.binding.mqtt.homeassistant.internal.handler;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ -23,20 +25,25 @@ import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.hamcrest.CoreMatchers;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests; import org.openhab.binding.mqtt.homeassistant.internal.AbstractHomeAssistantTests;
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
import org.openhab.binding.mqtt.homeassistant.internal.HaID; import org.openhab.binding.mqtt.homeassistant.internal.HaID;
import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration; import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
import org.openhab.binding.mqtt.homeassistant.internal.component.Climate; import org.openhab.binding.mqtt.homeassistant.internal.component.Climate;
import org.openhab.binding.mqtt.homeassistant.internal.component.Sensor; import org.openhab.binding.mqtt.homeassistant.internal.component.Sensor;
import org.openhab.binding.mqtt.homeassistant.internal.component.Switch; import org.openhab.binding.mqtt.homeassistant.internal.component.Switch;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.thing.Channel; import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.binding.ThingHandlerCallback; import org.openhab.core.thing.binding.ThingHandlerCallback;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.types.StateDescription; import org.openhab.core.types.StateDescription;
import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.Jinjava;
@ -76,6 +83,10 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing); when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
setupThingHandler();
}
protected void setupThingHandler() {
thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, stateDescriptionProvider, thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
channelTypeRegistry, new Jinjava(), SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT); channelTypeRegistry, new Jinjava(), SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
thingHandler.setConnection(bridgeConnection); thingHandler.setConnection(bridgeConnection);
@ -100,7 +111,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
}); });
verify(thingHandler, never()).componentDiscovered(any(), any()); verify(thingHandler, never()).componentDiscovered(any(), any());
assertThat(haThing.getChannels().size(), CoreMatchers.is(0)); assertThat(haThing.getChannels().size(), is(0));
// Components discovered after messages in corresponding topics // Components discovered after messages in corresponding topics
var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config"; var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
thingHandler.discoverComponents.processMessage(configTopic, thingHandler.discoverComponents.processMessage(configTopic,
@ -108,7 +119,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class)); verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Climate.class));
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(6)); assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(6));
verify(stateDescriptionProvider, times(6)).setDescription(any(), any(StateDescription.class)); verify(stateDescriptionProvider, times(6)).setDescription(any(), any(StateDescription.class));
verify(channelTypeProvider, times(1)).putChannelGroupType(any()); verify(channelTypeProvider, times(1)).putChannelGroupType(any());
@ -119,7 +130,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class)); verify(thingHandler, times(1)).componentDiscovered(eq(new HaID(configTopic)), any(Switch.class));
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7)); assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class)); verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
verify(channelTypeProvider, times(3)).putChannelGroupType(any()); verify(channelTypeProvider, times(3)).putChannelGroupType(any());
} }
@ -144,7 +155,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
}); });
verify(thingHandler, never()).componentDiscovered(any(), any()); verify(thingHandler, never()).componentDiscovered(any(), any());
assertThat(haThing.getChannels().size(), CoreMatchers.is(0)); assertThat(haThing.getChannels().size(), is(0));
// //
// //
@ -211,13 +222,13 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
// verify that both channels are there and the label corresponds to newer discovery topic payload // verify that both channels are there and the label corresponds to newer discovery topic payload
// //
Channel corridorTempChannel = nonSpyThingHandler.getThing().getChannel("tempCorridor_5Fsensor#sensor"); Channel corridorTempChannel = nonSpyThingHandler.getThing().getChannel("tempCorridor_5Fsensor#sensor");
assertThat("Corridor temperature channel is created", corridorTempChannel, CoreMatchers.notNullValue()); assertThat("Corridor temperature channel is created", corridorTempChannel, notNullValue());
Objects.requireNonNull(corridorTempChannel); // for compiler Objects.requireNonNull(corridorTempChannel); // for compiler
assertThat("Corridor temperature channel is having the updated label from 2nd discovery topic publish", assertThat("Corridor temperature channel is having the updated label from 2nd discovery topic publish",
corridorTempChannel.getLabel(), CoreMatchers.is("CorridorTemp NEW")); corridorTempChannel.getLabel(), is("CorridorTemp NEW"));
Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor"); Channel outsideTempChannel = nonSpyThingHandler.getThing().getChannel("tempOutside_5Fsensor#sensor");
assertThat("Outside temperature channel is created", outsideTempChannel, CoreMatchers.notNullValue()); assertThat("Outside temperature channel is created", outsideTempChannel, notNullValue());
verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class)); verify(thingHandler, times(2)).componentDiscovered(eq(new HaID(configTopicTempCorridor)), any(Sensor.class));
@ -242,7 +253,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
"homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config", "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
getResourceAsByteArray("component/configTS0601AutoLock.json")); getResourceAsByteArray("component/configTS0601AutoLock.json"));
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7)); assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class)); verify(stateDescriptionProvider, atLeast(7)).setDescription(any(), any(StateDescription.class));
// When dispose // When dispose
@ -270,7 +281,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
"homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config", "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
getResourceAsByteArray("component/configTS0601AutoLock.json")); getResourceAsByteArray("component/configTS0601AutoLock.json"));
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7)); assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
// When dispose // When dispose
nonSpyThingHandler.handleRemoval(); nonSpyThingHandler.handleRemoval();
@ -288,7 +299,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
"{}".getBytes(StandardCharsets.UTF_8)); "{}".getBytes(StandardCharsets.UTF_8));
// Ignore unsupported component // Ignore unsupported component
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(haThing.getChannels().size(), CoreMatchers.is(0)); assertThat(haThing.getChannels().size(), is(0));
} }
@Test @Test
@ -298,7 +309,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
"".getBytes(StandardCharsets.UTF_8)); "".getBytes(StandardCharsets.UTF_8));
// Ignore component with empty config // Ignore component with empty config
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(haThing.getChannels().size(), CoreMatchers.is(0)); assertThat(haThing.getChannels().size(), is(0));
} }
@Test @Test
@ -308,6 +319,39 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
"{bad format}}".getBytes(StandardCharsets.UTF_8)); "{bad format}}".getBytes(StandardCharsets.UTF_8));
// Ignore component with bad format config // Ignore component with bad format config
thingHandler.delayedProcessing.forceProcessNow(); thingHandler.delayedProcessing.forceProcessNow();
assertThat(haThing.getChannels().size(), CoreMatchers.is(0)); assertThat(haThing.getChannels().size(), is(0));
}
@Test
public void testRestoreComponentFromChannelConfig() {
Configuration thingConfiguration = new Configuration();
thingConfiguration.put("topics", List.of("switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/switch"));
Configuration channelConfiguration = new Configuration();
channelConfiguration.put("component", "switch");
channelConfiguration.put("objectid", List.of("switch"));
channelConfiguration.put("nodeid", "0x847127fffe11dd6a_auto_lock_zigbee2mqtt");
channelConfiguration.put("config", List.of("""
{
"command_topic": "zigbee2mqtt/th1/set/auto_lock",
"name": "th1 auto lock",
"state_topic": "zigbee2mqtt/th1",
"unique_id": "0x847127fffe11dd6a_auto_lock_zigbee2mqtt"
}
"""));
ChannelBuilder channelBuilder = ChannelBuilder
.create(new ChannelUID(haThing.getUID(), "switch"), CoreItemFactory.SWITCH)
.withType(ComponentChannelType.SWITCH.getChannelTypeUID()).withConfiguration(channelConfiguration);
haThing = ThingBuilder.create(HA_TYPE_UID, HA_UID).withBridge(BRIDGE_UID).withChannel(channelBuilder.build())
.withConfiguration(thingConfiguration).build();
haThing.setProperty("newStyleChannels", "true");
setupThingHandler();
thingHandler.initialize();
assertThat(thingHandler.getComponents().size(), is(1));
assertThat(thingHandler.getComponents().keySet().iterator().next(), is("switch"));
assertThat(thingHandler.getComponents().values().iterator().next().getClass(), is(Switch.class));
} }
} }