]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] Use a single channel for all events from a single button (#17598)
authorCody Cutrer <cody@cutrer.us>
Mon, 21 Oct 2024 19:24:56 +0000 (14:24 -0500)
committerGitHub <noreply@github.com>
Mon, 21 Oct 2024 19:24:56 +0000 (21:24 +0200)
Use the `subtype` field to collapse multiple DeviceAutomation components into
a single channel.

Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HaID.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrigger.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/config/homeassistant-channel-config.xml
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/AbstractHomeAssistantTests.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/HaIDTests.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTriggerTests.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandlerTests.java

index 1be08c07bea8120a5c72213332eca3123fd1ace7..d10ee4168a445d02185afe87a0635a7b187ca1f8 100644 (file)
@@ -85,8 +85,12 @@ public class ComponentChannel {
         channelState.setChannelUID(channelUID);
     }
 
+    public void resetConfiguration(Configuration configuration) {
+        channel = ChannelBuilder.create(channel).withConfiguration(configuration).build();
+    }
+
     public void clearConfiguration() {
-        channel = ChannelBuilder.create(channel).withConfiguration(new Configuration()).build();
+        resetConfiguration(new Configuration());
     }
 
     public ChannelState getState() {
@@ -142,6 +146,8 @@ public class ComponentChannel {
         private @Nullable String templateIn;
         private @Nullable String templateOut;
 
+        private @Nullable Configuration configuration;
+
         private String format = "%s";
 
         public Builder(AbstractComponent<?> component, String channelID, ChannelTypeUID channelTypeUID,
@@ -225,6 +231,11 @@ public class ComponentChannel {
             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
         // put it in optimistic mode (which, in openHAB parlance, means to auto-update the
         // item).
@@ -286,9 +297,12 @@ public class ComponentChannel {
                 commandDescription = valueState.createCommandDescription().build();
             }
 
-            Configuration configuration = new Configuration();
-            configuration.put("config", component.getChannelConfigurationJson());
-            component.getHaID().toConfig(configuration);
+            Configuration configuration = this.configuration;
+            if (configuration == null) {
+                configuration = new Configuration();
+                configuration.put("config", component.getChannelConfigurationJson());
+                component.getHaID().toConfig(configuration);
+            }
 
             channel = ChannelBuilder.create(channelUID, channelState.getItemType()).withType(channelTypeUID)
                     .withKind(kind).withLabel(label).withConfiguration(configuration)
index cd61ed8a85157cc55a36a165a36604dac2500ce1..7abaf4bcb151b1774882bf612b43d0cb03d92ad0 100644 (file)
@@ -91,11 +91,11 @@ public class HaID {
 
     private static String createTopic(HaID id) {
         StringBuilder str = new StringBuilder();
-        str.append(id.baseTopic).append('/').append(id.component).append('/');
+        str.append(id.component).append('/');
         if (!id.nodeID.isBlank()) {
             str.append(id.nodeID).append('/');
         }
-        str.append(id.objectID).append('/');
+        str.append(id.objectID);
         return str.toString();
     }
 
@@ -232,7 +232,7 @@ public class HaID {
      * @return fallback group id
      */
     public String getTopic(String suffix) {
-        return topic + suffix;
+        return baseTopic + "/" + topic + "/" + suffix;
     }
 
     @Override
index bd6beb4075391161a3c159f183bc20bab61c1d0f..d3e1ebcc047f5914c9f5e5f6720c8feae438829a 100644 (file)
  */
 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.Nullable;
 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.config.dto.AbstractChannelConfiguration;
 import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
+import org.openhab.core.config.core.Configuration;
 
 import com.google.gson.annotations.SerializedName;
 
@@ -31,7 +37,7 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
     /**
      * Configuration class for MQTT component
      */
-    static class ChannelConfiguration extends AbstractChannelConfiguration {
+    public static class ChannelConfiguration extends AbstractChannelConfiguration {
         ChannelConfiguration() {
             super("MQTT Device Trigger");
         }
@@ -43,6 +49,14 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
         protected String subtype = "";
 
         protected @Nullable String payload;
+
+        public String getTopic() {
+            return topic;
+        }
+
+        public String getSubtype() {
+            return subtype;
+        }
     }
 
     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");
         }
 
+        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;
         String payload = channelConfiguration.payload;
         if (payload != null) {
@@ -69,6 +91,54 @@ public class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
         buildChannel(channelConfiguration.type, ComponentChannelType.TRIGGER, value, getName(),
                 componentConfiguration.getUpdateListener())
                 .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;
     }
 }
index 1143a8e669061cd3de002310aea951ed3640612a..7609fa7a44dd41964bbaa79e71bc7e2b967dfca7 100644 (file)
 package org.openhab.binding.mqtt.homeassistant.internal.handler;
 
 import java.net.URI;
+import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 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.component.AbstractComponent;
 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.config.ChannelConfigurationTypeAdapterFactory;
 import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
@@ -156,35 +159,39 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
                     continue;
                 }
             }
-            Configuration channelConfig = channel.getConfiguration();
-            if (!channelConfig.containsKey("component")
-                    || !channelConfig.containsKey("objectid") | !channelConfig.containsKey("config")) {
+            Configuration multiComponentChannelConfig = channel.getConfiguration();
+            if (!multiComponentChannelConfig.containsKey("component")
+                    || !multiComponentChannelConfig.containsKey("objectid")
+                    || !multiComponentChannelConfig.containsKey("config")) {
                 // Must be a secondary channel
                 continue;
             }
 
-            HaID haID = HaID.fromConfig(config.basetopic, channelConfig);
+            List<Configuration> flattenedConfig = flattenChannelConfiguration(multiComponentChannelConfig,
+                    channel.getUID());
+            for (Configuration channelConfig : flattenedConfig) {
+                HaID haID = HaID.fromConfig(config.basetopic, channelConfig);
 
-            if (!config.topics.contains(haID.getTopic())) {
-                // don't add a component for this channel that isn't configured on the thing
-                // anymore
-                // It will disappear from the thing when the thing type is updated below
-                continue;
-            }
-
-            discoveryHomeAssistantIDs.add(haID);
-            ThingUID thingUID = channel.getUID().getThingUID();
-            String channelConfigurationJSON = (String) channelConfig.get("config");
-            try {
-                AbstractComponent<?> component = ComponentFactory.createComponent(thingUID, haID,
-                        channelConfigurationJSON, this, this, scheduler, gson, jinjava, newStyleChannels);
-                if (typeID.equals(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)) {
-                    typeID = calculateThingTypeUID(component);
+                if (!config.topics.contains(haID.getTopic())) {
+                    // don't add a component for this channel that isn't configured on the thing
+                    // anymore. It will disappear from the thing when the thing type is updated below
+                    continue;
                 }
 
-                addComponent(component);
-            } catch (ConfigurationException e) {
-                logger.warn("Cannot restore component {}: {}", thing, e.getMessage());
+                discoveryHomeAssistantIDs.add(haID);
+                ThingUID thingUID = channel.getUID().getThingUID();
+                String channelConfigurationJSON = (String) channelConfig.get("config");
+                try {
+                    AbstractComponent<?> component = ComponentFactory.createComponent(thingUID, haID,
+                            channelConfigurationJSON, this, this, scheduler, gson, jinjava, newStyleChannels);
+                    if (typeID.equals(MqttBindingConstants.HOMEASSISTANT_MQTT_THING)) {
+                        typeID = calculateThingTypeUID(component);
+                    }
+
+                    addComponent(component);
+                } catch (ConfigurationException e) {
+                    logger.warn("Cannot restore component {}: {}", thing, e.getMessage());
+                }
             }
         }
         if (updateThingType(typeID)) {
@@ -280,7 +287,8 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
                 AbstractComponent<?> known = haComponentsByUniqueId.get(discovered.getUniqueId());
                 // Is component already known?
                 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.
                         // The component will be replaced in a moment.
                         known.stop();
@@ -422,6 +430,35 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
     private void addComponent(AbstractComponent component) {
         AbstractComponent existing = haComponents.get(component.getComponentId());
         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
             haComponents.remove(existing.getComponentId());
             existing.resolveConflict();
@@ -431,4 +468,41 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
         haComponents.put(component.getComponentId(), 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;
+    }
 }
index d3092a82b11af499d98890cc3fcc7308ca10600e..864e5afd08c30a7ec5cbe9345721e96799621569 100644 (file)
                        <description>Optional node name of the component</description>
                        <default></default>
                </parameter>
-               <parameter name="objectid" type="text" readOnly="true">
+               <parameter name="objectid" type="text" readOnly="true" multiple="true">
                        <label>Object ID</label>
                        <description>Object ID of the component</description>
                        <default></default>
                </parameter>
-               <parameter name="config" type="text" readOnly="true">
+               <parameter name="config" type="text" readOnly="true" multiple="true">
                        <label>JSON Configuration</label>
                        <description>The JSON configuration string received by the component via MQTT.</description>
                        <default></default>
index 4ef9d6e2668794ef6f1775060967dce26b7bfd3c..645411963f569f00a645c093a87f9cfc91581c4b 100644 (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 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<>();
 
     private @Mock @NonNullByDefault({}) TransformationService transformationService1Mock;
index 3265a40b6b02682d03417ac1e08e4de9bb1d5b87..dbf5d2be45e0bcb5e0540ee59e8567d5e4bd1e98 100644 (file)
@@ -36,6 +36,7 @@ public class HaIDTests {
         assertThat(subject.objectID, is("name"));
 
         assertThat(subject.component, is("switch"));
+        assertThat(subject.getTopic(), is("switch/name"));
         assertThat(subject.getTopic("suffix"), is("homeassistant/switch/name/suffix"));
 
         Configuration config = new Configuration();
@@ -58,6 +59,7 @@ public class HaIDTests {
         assertThat(subject.objectID, is("name"));
 
         assertThat(subject.component, is("switch"));
+        assertThat(subject.getTopic(), is("switch/node/name"));
         assertThat(subject.getTopic("suffix"), is("homeassistant/switch/node/name/suffix"));
 
         Configuration config = new Configuration();
index 4b090b6385d9b7d8cba7863c0b66942e673f5fc8..2771859417bafd6fd909756f9c9de5b1b6d0d671 100644 (file)
@@ -80,6 +80,9 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
 
         when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
 
+        if (useNewStyleChannels()) {
+            haThing.setProperty("newStyleChannels", "true");
+        }
         thingHandler = new LatchThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
                 channelTypeRegistry, SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
         thingHandler.setConnection(bridgeConnection);
@@ -104,6 +107,13 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
      */
     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()}
      *
index baa476704eb949e050db8efd207c064cd7b136df..96becd8e2193caa6f984b86e2e013be466a4117d 100644 (file)
@@ -14,12 +14,18 @@ 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.assertNotNull;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
 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.binding.mqtt.homeassistant.internal.ComponentChannel;
+import org.openhab.core.config.core.Configuration;
 
 /**
  * Tests for {@link DeviceTrigger}
@@ -28,12 +34,13 @@ import org.openhab.binding.mqtt.generic.values.TextValue;
  */
 @NonNullByDefault
 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")
     @Test
     public void test() throws InterruptedException {
-        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """
+        var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC_1), """
                 {
                     "automation_type": "trigger",
                     "device": {
@@ -56,19 +63,93 @@ public class DeviceTriggerTests extends AbstractComponentTests {
         assertThat(component.channels.size(), is(1));
         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);
 
-        spyOnChannelUpdates(component, "action");
+        spyOnChannelUpdates(component, "on");
         publishMessage("zigbee2mqtt/Charge Now Button/action", "on");
-        assertTriggered(component, "action", "on");
+        assertTriggered(component, "on", "on");
 
         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
     protected Set<String> getConfigTopics() {
-        return Set.of(CONFIG_TOPIC);
+        return Set.of(CONFIG_TOPIC_1, CONFIG_TOPIC_2);
+    }
+
+    @Override
+    protected boolean useNewStyleChannels() {
+        return true;
     }
 }
index ab469881547f927aea6003d1b39f809a87b6987f..26932da0396813ba10cd3c17608b503b132ec804 100644 (file)
@@ -12,6 +12,8 @@
  */
 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.mockito.ArgumentMatchers.*;
 import static org.mockito.Mockito.*;
@@ -23,20 +25,25 @@ import java.util.Objects;
 import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.hamcrest.CoreMatchers;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 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.HandlerConfiguration;
 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.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.ChannelUID;
 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 com.hubspot.jinjava.Jinjava;
@@ -76,6 +83,10 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
 
         when(callbackMock.getBridge(eq(BRIDGE_UID))).thenReturn(bridgeThing);
 
+        setupThingHandler();
+    }
+
+    protected void setupThingHandler() {
         thingHandler = new HomeAssistantThingHandler(haThing, channelTypeProvider, stateDescriptionProvider,
                 channelTypeRegistry, new Jinjava(), SUBSCRIBE_TIMEOUT, ATTRIBUTE_RECEIVE_TIMEOUT);
         thingHandler.setConnection(bridgeConnection);
@@ -100,7 +111,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
         });
 
         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
         var configTopic = "homeassistant/climate/0x847127fffe11dd6a_climate_zigbee2mqtt/config";
         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));
 
         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(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));
 
         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(channelTypeProvider, times(3)).putChannelGroupType(any());
     }
@@ -144,7 +155,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
         });
 
         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
         //
         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
         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");
-        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));
 
@@ -242,7 +253,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
                 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
                 getResourceAsByteArray("component/configTS0601AutoLock.json"));
         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));
 
         // When dispose
@@ -270,7 +281,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
                 "homeassistant/switch/0x847127fffe11dd6a_auto_lock_zigbee2mqtt/config",
                 getResourceAsByteArray("component/configTS0601AutoLock.json"));
         thingHandler.delayedProcessing.forceProcessNow();
-        assertThat(nonSpyThingHandler.getThing().getChannels().size(), CoreMatchers.is(7));
+        assertThat(nonSpyThingHandler.getThing().getChannels().size(), is(7));
 
         // When dispose
         nonSpyThingHandler.handleRemoval();
@@ -288,7 +299,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
                 "{}".getBytes(StandardCharsets.UTF_8));
         // Ignore unsupported component
         thingHandler.delayedProcessing.forceProcessNow();
-        assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
+        assertThat(haThing.getChannels().size(), is(0));
     }
 
     @Test
@@ -298,7 +309,7 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
                 "".getBytes(StandardCharsets.UTF_8));
         // Ignore component with empty config
         thingHandler.delayedProcessing.forceProcessNow();
-        assertThat(haThing.getChannels().size(), CoreMatchers.is(0));
+        assertThat(haThing.getChannels().size(), is(0));
     }
 
     @Test
@@ -308,6 +319,39 @@ public class HomeAssistantThingHandlerTests extends AbstractHomeAssistantTests {
                 "{bad format}}".getBytes(StandardCharsets.UTF_8));
         // Ignore component with bad format config
         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));
     }
 }