diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java index 4bee6d2440561..f15d27936b9c6 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java @@ -43,6 +43,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Availability; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AvailabilityMode; import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Device; +import org.openhab.core.config.core.Configuration; import org.openhab.core.io.transport.mqtt.MqttBrokerConnection; import org.openhab.core.library.unit.ImperialUnits; import org.openhab.core.library.unit.SIUnits; @@ -416,4 +417,45 @@ public C getChannelConfiguration() { private ChannelGroupTypeUID getChannelGroupTypeUID(String prefix) { return new ChannelGroupTypeUID(MqttBindingConstants.BINDING_ID, prefix + "_" + uniqueId); } + + public boolean mergeable(AbstractComponent other) { + return false; + } + + protected Configuration mergeChannelConfiguration(ComponentChannel channel, AbstractComponent other) { + 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) { + if (!objectIdString.equals(other.getHaID().objectID)) { + 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)) + .sorted().distinct().toList()); + } + Object configObject = currentConfiguration.get("config"); + if (configObject instanceof String configString) { + if (!configString.equals(other.getChannelConfigurationJson())) { + 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())).sorted() + .distinct().toList()); + } + return newConfiguration; + } + + /** + * Take another component of the same type, and merge it so that only one (set of) + * channel(s) exist on the Thing. + * + * @return if the component was stopped, and thus needs restarted + */ + public boolean merge(AbstractComponent other) { + return false; + } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrigger.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrigger.java index 746c98c66caeb..6c4de70323657 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrigger.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrigger.java @@ -12,7 +12,7 @@ */ package org.openhab.binding.mqtt.homeassistant.internal.component; -import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Stream; @@ -91,44 +91,40 @@ public DeviceTrigger(ComponentFactory.ComponentConfiguration componentConfigurat .stateTopic(channelConfiguration.topic, channelConfiguration.getValueTemplate()).trigger(true).build(); } + @Override + public boolean mergeable(AbstractComponent other) { + if (other instanceof DeviceTrigger newTrigger + && newTrigger.getChannelConfiguration().getSubtype().equals(getChannelConfiguration().getSubtype()) + && newTrigger.getChannelConfiguration().getTopic().equals(getChannelConfiguration().getTopic()) + && getHaID().nodeID.equals(newTrigger.getHaID().nodeID)) { + String newTriggerValueTemplate = newTrigger.getChannelConfiguration().getValueTemplate(); + String oldTriggerValueTemplate = getChannelConfiguration().getValueTemplate(); + if ((newTriggerValueTemplate == null && oldTriggerValueTemplate == null) + || (newTriggerValueTemplate != null & oldTriggerValueTemplate != null + && newTriggerValueTemplate.equals(oldTriggerValueTemplate))) { + return true; + } + } + return false; + } + /** * 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 */ + @Override + public boolean merge(AbstractComponent other) { + DeviceTrigger newTrigger = (DeviceTrigger) other; + ComponentChannel channel = Objects.requireNonNull(channels.get(componentId)); + Configuration newConfiguration = mergeChannelConfiguration(channel, newTrigger); - public boolean merge(DeviceTrigger other) { - ComponentChannel channel = channels.get(componentId); TextValue value = (TextValue) channel.getState().getCache(); Set 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) { - if (!objectIdString.equals(other.getHaID().objectID)) { - 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)) - .sorted().distinct().toList()); - } - Object configObject = currentConfiguration.get("config"); - if (configObject instanceof String configString) { - if (!configString.equals(other.getChannelConfigurationJson())) { - 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())).sorted() - .distinct().toList()); - } // Append payload to allowed values - String otherPayload = other.getChannelConfiguration().payload; + String otherPayload = newTrigger.getChannelConfiguration().payload; if (payloads == null || otherPayload == null) { // Need to accept anything value = new TextValue(); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Scene.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Scene.java index 2229570bf7345..5ceac5a8fe29d 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Scene.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Scene.java @@ -12,12 +12,24 @@ */ package org.openhab.binding.mqtt.homeassistant.internal.component; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.ChannelState; 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 org.openhab.core.library.types.StringType; import org.openhab.core.thing.type.AutoUpdatePolicy; +import org.openhab.core.types.Command; +import org.openhab.core.types.CommandDescriptionBuilder; +import org.openhab.core.types.CommandOption; import com.google.gson.annotations.SerializedName; @@ -30,6 +42,29 @@ public class Scene extends AbstractComponent { public static final String SCENE_CHANNEL_ID = "scene"; + // A command that has already been processed and routed to the correct Value, + // and should be immediately published. This will be the payloadOn value from + // the configuration + private static class SceneCommand extends StringType { + SceneCommand(String value) { + super(value); + } + } + + // A value that can provide a proper CommandDescription with values and labels + class SceneValue extends TextValue { + SceneValue() { + super(); + } + + @Override + public CommandDescriptionBuilder createCommandDescription() { + CommandDescriptionBuilder builder = super.createCommandDescription(); + objectIdToScene.forEach((k, v) -> builder.withCommandOption(new CommandOption(k, v.getName()))); + return builder; + } + } + /** * Configuration class for MQTT component */ @@ -39,23 +74,106 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { } @SerializedName("command_topic") - protected @Nullable String commandTopic; + protected String commandTopic = ""; @SerializedName("payload_on") protected String payloadOn = "ON"; } + // Keeps track of discrete command topics, and one SceneValue that uses that topic + private final Map topicsToChannelStates = new HashMap<>(); + private final Map objectIdToScene = new TreeMap<>(); + private final Map labelToScene = new HashMap<>(); + + private final SceneValue value = new SceneValue(); + private ComponentChannel channel; + public Scene(ComponentFactory.ComponentConfiguration componentConfiguration) { super(componentConfiguration, ChannelConfiguration.class); - TextValue value = new TextValue(new String[] { channelConfiguration.payloadOn }); + if (channelConfiguration.commandTopic.isEmpty()) { + throw new ConfigurationException("command_topic is required"); + } + + // Name the channel with a constant, not the component ID + // So that we only end up with a single channel for all scenes + componentId = SCENE_CHANNEL_ID; + groupId = null; - buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, getName(), + channel = buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, getName(), componentConfiguration.getUpdateListener()) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + .commandFilter(this::handleCommand).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + topicsToChannelStates.put(channelConfiguration.commandTopic, channel.getState()); + addScene(this); + } - finalizeChannels(); + ComponentChannel getChannel() { + return channel; + } + + private void addScene(Scene scene) { + ChannelConfiguration channelConfiguration = scene.getChannelConfiguration(); + objectIdToScene.put(scene.getHaID().objectID, channelConfiguration); + labelToScene.put(channelConfiguration.getName(), channelConfiguration); + + if (!topicsToChannelStates.containsKey(channelConfiguration.commandTopic)) { + hiddenChannels.add(scene.getChannel()); + topicsToChannelStates.put(channelConfiguration.commandTopic, scene.getChannel().getState()); + } + } + + private boolean handleCommand(Command command) { + // This command has already been processed by the rest of this method, + // so just return immediately. + if (command instanceof SceneCommand) { + return true; + } + + String valueStr = command.toString(); + ChannelConfiguration sceneConfig = objectIdToScene.get(valueStr); + if (sceneConfig == null) { + sceneConfig = labelToScene.get(command.toString()); + } + if (sceneConfig == null) { + throw new IllegalArgumentException("Value " + valueStr + " not within range"); + } + + ChannelState state = Objects.requireNonNull(topicsToChannelStates.get(sceneConfig.commandTopic)); + // This will end up calling this same method, so be sure no further processing is done + state.publishValue(new SceneCommand(sceneConfig.payloadOn)); + + return false; + } + + @Override + public String getName() { + return "Scene"; + } + + @Override + public boolean mergeable(AbstractComponent other) { + return other instanceof Scene; + } + + @Override + public boolean merge(AbstractComponent other) { + Scene newScene = (Scene) other; + Configuration newConfiguration = mergeChannelConfiguration(channel, newScene); + + addScene(newScene); + + // Recreate the channel so that the configuration will have all the scenes + stop(); + channel = buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, "Scene", + componentConfiguration.getUpdateListener()) + .withConfiguration(newConfiguration) + .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), + channelConfiguration.getQos()) + .commandFilter(this::handleCommand).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + // New ChannelState created; need to make sure we're referencing the correct one + topicsToChannelStates.put(channelConfiguration.commandTopic, channel.getState()); + return true; } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java index 5ac510dd524f4..68dc69b626377 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java @@ -35,7 +35,7 @@ public abstract class AbstractChannelConfiguration { public static final char PARENT_TOPIC_PLACEHOLDER = '~'; private static final String DEFAULT_THING_NAME = "Home Assistant Device"; - protected @Nullable String name; + protected String name; protected String icon = ""; protected int qos; // defaults to 0 according to HA specification @@ -136,7 +136,7 @@ public Map appendToProperties(Map properties) { return properties; } - public @Nullable String getName() { + public String getName() { return name; } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java index 4aecc25a7df47..77286bce64d49 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java @@ -42,7 +42,6 @@ import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelLinkageChecker; 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; @@ -482,33 +481,19 @@ private void releaseStateUpdated(Update.ReleaseState state) { private boolean 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); - haComponentsByHaId.put(component.getHaID(), component); - return false; + // Check for components that merge together + if (component.mergeable(existing)) { + MqttBrokerConnection connection = this.connection; + if (existing.merge(component) && connection != null) { + // Make sure to re-start if this did something, and it was stopped + existing.start(connection, scheduler, 0).exceptionally(e -> { + logger.warn("Failed to start component {}", existing.getHaID(), e); + return null; + }); } + haComponentsByUniqueId.put(component.getUniqueId(), component); + haComponentsByHaId.put(component.getHaID(), component); + return false; } // rename the conflict diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SceneTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SceneTests.java new file mode 100644 index 0000000000000..72e1329e8c048 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/SceneTests.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2010-2025 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.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.Value; +import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.library.types.StringType; +import org.openhab.core.types.CommandOption; + +/** + * Tests for {@link DeviceTrigger} + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class SceneTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC_1 = "scene/12345_14/scene_1"; + public static final String CONFIG_TOPIC_2 = "scene/12345_14/scene_2"; + + @SuppressWarnings("null") + @Test + public void test() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC_1), """ + { + "command_topic": "zigbee2mqtt/Theater Room Lights/set", + "name": "House", + "object_id": "theater_room_lights_1_house", + "payload_on": "{ \\"scene_recall\\": 1 }", + "unique_id": "14_scene_1_zigbee2mqtt" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("Scene")); + + assertChannel(component, Scene.SCENE_CHANNEL_ID, "", "zigbee2mqtt/Theater Room Lights/set", "Scene", + Scene.SceneValue.class); + linkAllChannels(component); + + component.getChannel(Scene.SCENE_CHANNEL_ID).getState().publishValue(new StringType("scene_1")); + assertPublished("zigbee2mqtt/Theater Room Lights/set", "{ \"scene_recall\": 1 }"); + + component.getChannel(Scene.SCENE_CHANNEL_ID).getState().publishValue(new StringType("House")); + assertPublished("zigbee2mqtt/Theater Room Lights/set", "{ \"scene_recall\": 1 }", 2); + } + + @SuppressWarnings("null") + @Test + public void testMerge() throws InterruptedException { + var component1 = (Scene) discoverComponent(configTopicToMqtt(CONFIG_TOPIC_1), """ + { + "command_topic": "zigbee2mqtt/Theater Room Lights/set", + "name": "House", + "object_id": "theater_room_lights_1_house", + "payload_on": "{ \\"scene_recall\\": 1 }", + "unique_id": "14_scene_1_zigbee2mqtt" + } + """); + discoverComponent(configTopicToMqtt(CONFIG_TOPIC_2), """ + { + "command_topic": "zigbee2mqtt/Theater Room Lights/set", + "name": "Menu", + "object_id": "theater_room_lights_2_menu", + "payload_on": "{ \\"scene_recall\\": 2 }", + "unique_id": "14_scene_2_zigbee2mqtt" + } + """); + + assertThat(component1.channels.size(), is(1)); + + ComponentChannel channel = Objects.requireNonNull(component1.getChannel(Scene.SCENE_CHANNEL_ID)); + Value value = channel.getState().getCache(); + List options = value.createCommandDescription().build().getCommandOptions(); + assertThat(options.size(), is(2)); + assertThat(options.get(0).getCommand(), is("scene_1")); + assertThat(options.get(1).getCommand(), is("scene_2")); + 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)); + + linkAllChannels(component1); + + component1.getChannel(Scene.SCENE_CHANNEL_ID).getState().publishValue(new StringType("House")); + assertPublished("zigbee2mqtt/Theater Room Lights/set", "{ \"scene_recall\": 1 }"); + + component1.getChannel(Scene.SCENE_CHANNEL_ID).getState().publishValue(new StringType("scene_2")); + assertPublished("zigbee2mqtt/Theater Room Lights/set", "{ \"scene_recall\": 2 }"); + } + + @SuppressWarnings("null") + @Test + public void testMultipleTopics() throws InterruptedException { + var component1 = (Scene) discoverComponent(configTopicToMqtt(CONFIG_TOPIC_1), """ + { + "command_topic": "zigbee2mqtt/Theater Room Lights/set", + "name": "House", + "object_id": "theater_room_lights_1_house", + "payload_on": "{ \\"scene_recall\\": 1 }", + "unique_id": "14_scene_1_zigbee2mqtt" + } + """); + discoverComponent(configTopicToMqtt(CONFIG_TOPIC_2), """ + { + "command_topic": "zigbee2mqtt/Theater Room Lights 2/set", + "name": "Menu", + "object_id": "theater_room_lights_2_menu", + "payload_on": "{ \\"scene_recall\\": 2 }", + "unique_id": "14_scene_2_zigbee2mqtt" + } + """); + + assertThat(component1.channels.size(), is(1)); + + ComponentChannel channel = Objects.requireNonNull(component1.getChannel(Scene.SCENE_CHANNEL_ID)); + Value value = channel.getState().getCache(); + List options = value.createCommandDescription().build().getCommandOptions(); + assertThat(options.size(), is(2)); + assertThat(options.get(0).getCommand(), is("scene_1")); + assertThat(options.get(1).getCommand(), is("scene_2")); + 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)); + + linkAllChannels(component1); + + component1.getChannel(Scene.SCENE_CHANNEL_ID).getState().publishValue(new StringType("House")); + assertPublished("zigbee2mqtt/Theater Room Lights/set", "{ \"scene_recall\": 1 }"); + + component1.getChannel(Scene.SCENE_CHANNEL_ID).getState().publishValue(new StringType("scene_2")); + assertPublished("zigbee2mqtt/Theater Room Lights 2/set", "{ \"scene_recall\": 2 }"); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC_1, CONFIG_TOPIC_2); + } +}