[mqtt.homeassistant] Use a single channel for all scenes on a device (#18262)

It accepts either object ID, or scene name (assuming the latter doesn't
conflict with the former). 

Signed-off-by: Cody Cutrer <cody@cutrer.us>
pull/17817/merge
Cody Cutrer 2025-02-18 05:30:51 -07:00 committed by GitHub
parent a41c6d4b4f
commit 1856aa9b3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 369 additions and 63 deletions

View File

@ -43,6 +43,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChanne
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 abstract class AbstractComponent<C extends AbstractChannelConfiguration>
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<C> 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;
}
}

View File

@ -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 class DeviceTrigger extends AbstractComponent<DeviceTrigger.ChannelConfig
.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<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) {
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();

View File

@ -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 @@ import com.google.gson.annotations.SerializedName;
public class Scene extends AbstractComponent<Scene.ChannelConfiguration> {
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 @@ public class Scene extends AbstractComponent<Scene.ChannelConfiguration> {
}
@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<String, ChannelState> topicsToChannelStates = new HashMap<>();
private final Map<String, ChannelConfiguration> objectIdToScene = new TreeMap<>();
private final Map<String, ChannelConfiguration> 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");
}
buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, getName(),
// 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;
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;
}
}

View File

@ -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 abstract class AbstractChannelConfiguration {
return properties;
}
public @Nullable String getName() {
public String getName() {
return name;
}

View File

@ -42,7 +42,6 @@ import org.openhab.binding.mqtt.homeassistant.internal.HandlerConfiguration;
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 @@ public class HomeAssistantThingHandler extends AbstractMQTTThingHandler
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

View File

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