Skip to content

Commit 1856aa9

Browse files
authored
[mqtt.homeassistant] Use a single channel for all scenes on a device (openhab#18262)
It accepts either object ID, or scene name (assuming the latter doesn't conflict with the former). Signed-off-by: Cody Cutrer <[email protected]>
1 parent a41c6d4 commit 1856aa9

File tree

6 files changed

+369
-63
lines changed

6 files changed

+369
-63
lines changed

bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java

+42
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Availability;
4444
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AvailabilityMode;
4545
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.Device;
46+
import org.openhab.core.config.core.Configuration;
4647
import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
4748
import org.openhab.core.library.unit.ImperialUnits;
4849
import org.openhab.core.library.unit.SIUnits;
@@ -416,4 +417,45 @@ public C getChannelConfiguration() {
416417
private ChannelGroupTypeUID getChannelGroupTypeUID(String prefix) {
417418
return new ChannelGroupTypeUID(MqttBindingConstants.BINDING_ID, prefix + "_" + uniqueId);
418419
}
420+
421+
public boolean mergeable(AbstractComponent<?> other) {
422+
return false;
423+
}
424+
425+
protected Configuration mergeChannelConfiguration(ComponentChannel channel, AbstractComponent<C> other) {
426+
Configuration currentConfiguration = channel.getChannel().getConfiguration();
427+
Configuration newConfiguration = new Configuration();
428+
newConfiguration.put("component", currentConfiguration.get("component"));
429+
newConfiguration.put("nodeid", currentConfiguration.get("nodeid"));
430+
Object objectIdObject = currentConfiguration.get("objectid");
431+
if (objectIdObject instanceof String objectIdString) {
432+
if (!objectIdString.equals(other.getHaID().objectID)) {
433+
newConfiguration.put("objectid", List.of(objectIdString, other.getHaID().objectID));
434+
}
435+
} else if (objectIdObject instanceof List<?> objectIdList) {
436+
newConfiguration.put("objectid", Stream.concat(objectIdList.stream(), Stream.of(other.getHaID().objectID))
437+
.sorted().distinct().toList());
438+
}
439+
Object configObject = currentConfiguration.get("config");
440+
if (configObject instanceof String configString) {
441+
if (!configString.equals(other.getChannelConfigurationJson())) {
442+
newConfiguration.put("config", List.of(configString, other.getChannelConfigurationJson()));
443+
}
444+
} else if (configObject instanceof List<?> configList) {
445+
newConfiguration.put("config",
446+
Stream.concat(configList.stream(), Stream.of(other.getChannelConfigurationJson())).sorted()
447+
.distinct().toList());
448+
}
449+
return newConfiguration;
450+
}
451+
452+
/**
453+
* Take another component of the same type, and merge it so that only one (set of)
454+
* channel(s) exist on the Thing.
455+
*
456+
* @return if the component was stopped, and thus needs restarted
457+
*/
458+
public boolean merge(AbstractComponent<?> other) {
459+
return false;
460+
}
419461
}

bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrigger.java

+24-28
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
*/
1313
package org.openhab.binding.mqtt.homeassistant.internal.component;
1414

15-
import java.util.List;
15+
import java.util.Objects;
1616
import java.util.Set;
1717
import java.util.stream.Stream;
1818

@@ -91,44 +91,40 @@ public DeviceTrigger(ComponentFactory.ComponentConfiguration componentConfigurat
9191
.stateTopic(channelConfiguration.topic, channelConfiguration.getValueTemplate()).trigger(true).build();
9292
}
9393

94+
@Override
95+
public boolean mergeable(AbstractComponent<?> other) {
96+
if (other instanceof DeviceTrigger newTrigger
97+
&& newTrigger.getChannelConfiguration().getSubtype().equals(getChannelConfiguration().getSubtype())
98+
&& newTrigger.getChannelConfiguration().getTopic().equals(getChannelConfiguration().getTopic())
99+
&& getHaID().nodeID.equals(newTrigger.getHaID().nodeID)) {
100+
String newTriggerValueTemplate = newTrigger.getChannelConfiguration().getValueTemplate();
101+
String oldTriggerValueTemplate = getChannelConfiguration().getValueTemplate();
102+
if ((newTriggerValueTemplate == null && oldTriggerValueTemplate == null)
103+
|| (newTriggerValueTemplate != null & oldTriggerValueTemplate != null
104+
&& newTriggerValueTemplate.equals(oldTriggerValueTemplate))) {
105+
return true;
106+
}
107+
}
108+
return false;
109+
}
110+
94111
/**
95112
* Take another DeviceTrigger (presumably whose subtype, topic, and value template match),
96113
* and adjust this component's channel to accept the payload that trigger allows.
97114
*
98115
* @return if the component was stopped, and thus needs restarted
99116
*/
117+
@Override
118+
public boolean merge(AbstractComponent<?> other) {
119+
DeviceTrigger newTrigger = (DeviceTrigger) other;
120+
ComponentChannel channel = Objects.requireNonNull(channels.get(componentId));
121+
Configuration newConfiguration = mergeChannelConfiguration(channel, newTrigger);
100122

101-
public boolean merge(DeviceTrigger other) {
102-
ComponentChannel channel = channels.get(componentId);
103123
TextValue value = (TextValue) channel.getState().getCache();
104124
Set<String> payloads = value.getStates();
105-
// Append objectid/config to channel configuration
106-
Configuration currentConfiguration = channel.getChannel().getConfiguration();
107-
Configuration newConfiguration = new Configuration();
108-
newConfiguration.put("component", currentConfiguration.get("component"));
109-
newConfiguration.put("nodeid", currentConfiguration.get("nodeid"));
110-
Object objectIdObject = currentConfiguration.get("objectid");
111-
if (objectIdObject instanceof String objectIdString) {
112-
if (!objectIdString.equals(other.getHaID().objectID)) {
113-
newConfiguration.put("objectid", List.of(objectIdString, other.getHaID().objectID));
114-
}
115-
} else if (objectIdObject instanceof List<?> objectIdList) {
116-
newConfiguration.put("objectid", Stream.concat(objectIdList.stream(), Stream.of(other.getHaID().objectID))
117-
.sorted().distinct().toList());
118-
}
119-
Object configObject = currentConfiguration.get("config");
120-
if (configObject instanceof String configString) {
121-
if (!configString.equals(other.getChannelConfigurationJson())) {
122-
newConfiguration.put("config", List.of(configString, other.getChannelConfigurationJson()));
123-
}
124-
} else if (configObject instanceof List<?> configList) {
125-
newConfiguration.put("config",
126-
Stream.concat(configList.stream(), Stream.of(other.getChannelConfigurationJson())).sorted()
127-
.distinct().toList());
128-
}
129125

130126
// Append payload to allowed values
131-
String otherPayload = other.getChannelConfiguration().payload;
127+
String otherPayload = newTrigger.getChannelConfiguration().payload;
132128
if (payloads == null || otherPayload == null) {
133129
// Need to accept anything
134130
value = new TextValue();

bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Scene.java

+124-6
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,24 @@
1212
*/
1313
package org.openhab.binding.mqtt.homeassistant.internal.component;
1414

15+
import java.util.HashMap;
16+
import java.util.Map;
17+
import java.util.Objects;
18+
import java.util.TreeMap;
19+
1520
import org.eclipse.jdt.annotation.NonNullByDefault;
16-
import org.eclipse.jdt.annotation.Nullable;
21+
import org.openhab.binding.mqtt.generic.ChannelState;
1722
import org.openhab.binding.mqtt.generic.values.TextValue;
23+
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
1824
import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType;
1925
import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
26+
import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
27+
import org.openhab.core.config.core.Configuration;
28+
import org.openhab.core.library.types.StringType;
2029
import org.openhab.core.thing.type.AutoUpdatePolicy;
30+
import org.openhab.core.types.Command;
31+
import org.openhab.core.types.CommandDescriptionBuilder;
32+
import org.openhab.core.types.CommandOption;
2133

2234
import com.google.gson.annotations.SerializedName;
2335

@@ -30,6 +42,29 @@
3042
public class Scene extends AbstractComponent<Scene.ChannelConfiguration> {
3143
public static final String SCENE_CHANNEL_ID = "scene";
3244

45+
// A command that has already been processed and routed to the correct Value,
46+
// and should be immediately published. This will be the payloadOn value from
47+
// the configuration
48+
private static class SceneCommand extends StringType {
49+
SceneCommand(String value) {
50+
super(value);
51+
}
52+
}
53+
54+
// A value that can provide a proper CommandDescription with values and labels
55+
class SceneValue extends TextValue {
56+
SceneValue() {
57+
super();
58+
}
59+
60+
@Override
61+
public CommandDescriptionBuilder createCommandDescription() {
62+
CommandDescriptionBuilder builder = super.createCommandDescription();
63+
objectIdToScene.forEach((k, v) -> builder.withCommandOption(new CommandOption(k, v.getName())));
64+
return builder;
65+
}
66+
}
67+
3368
/**
3469
* Configuration class for MQTT component
3570
*/
@@ -39,23 +74,106 @@ static class ChannelConfiguration extends AbstractChannelConfiguration {
3974
}
4075

4176
@SerializedName("command_topic")
42-
protected @Nullable String commandTopic;
77+
protected String commandTopic = "";
4378

4479
@SerializedName("payload_on")
4580
protected String payloadOn = "ON";
4681
}
4782

83+
// Keeps track of discrete command topics, and one SceneValue that uses that topic
84+
private final Map<String, ChannelState> topicsToChannelStates = new HashMap<>();
85+
private final Map<String, ChannelConfiguration> objectIdToScene = new TreeMap<>();
86+
private final Map<String, ChannelConfiguration> labelToScene = new HashMap<>();
87+
88+
private final SceneValue value = new SceneValue();
89+
private ComponentChannel channel;
90+
4891
public Scene(ComponentFactory.ComponentConfiguration componentConfiguration) {
4992
super(componentConfiguration, ChannelConfiguration.class);
5093

51-
TextValue value = new TextValue(new String[] { channelConfiguration.payloadOn });
94+
if (channelConfiguration.commandTopic.isEmpty()) {
95+
throw new ConfigurationException("command_topic is required");
96+
}
97+
98+
// Name the channel with a constant, not the component ID
99+
// So that we only end up with a single channel for all scenes
100+
componentId = SCENE_CHANNEL_ID;
101+
groupId = null;
52102

53-
buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, getName(),
103+
channel = buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, getName(),
54104
componentConfiguration.getUpdateListener())
55105
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
56106
channelConfiguration.getQos())
57-
.withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
107+
.commandFilter(this::handleCommand).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
108+
topicsToChannelStates.put(channelConfiguration.commandTopic, channel.getState());
109+
addScene(this);
110+
}
58111

59-
finalizeChannels();
112+
ComponentChannel getChannel() {
113+
return channel;
114+
}
115+
116+
private void addScene(Scene scene) {
117+
ChannelConfiguration channelConfiguration = scene.getChannelConfiguration();
118+
objectIdToScene.put(scene.getHaID().objectID, channelConfiguration);
119+
labelToScene.put(channelConfiguration.getName(), channelConfiguration);
120+
121+
if (!topicsToChannelStates.containsKey(channelConfiguration.commandTopic)) {
122+
hiddenChannels.add(scene.getChannel());
123+
topicsToChannelStates.put(channelConfiguration.commandTopic, scene.getChannel().getState());
124+
}
125+
}
126+
127+
private boolean handleCommand(Command command) {
128+
// This command has already been processed by the rest of this method,
129+
// so just return immediately.
130+
if (command instanceof SceneCommand) {
131+
return true;
132+
}
133+
134+
String valueStr = command.toString();
135+
ChannelConfiguration sceneConfig = objectIdToScene.get(valueStr);
136+
if (sceneConfig == null) {
137+
sceneConfig = labelToScene.get(command.toString());
138+
}
139+
if (sceneConfig == null) {
140+
throw new IllegalArgumentException("Value " + valueStr + " not within range");
141+
}
142+
143+
ChannelState state = Objects.requireNonNull(topicsToChannelStates.get(sceneConfig.commandTopic));
144+
// This will end up calling this same method, so be sure no further processing is done
145+
state.publishValue(new SceneCommand(sceneConfig.payloadOn));
146+
147+
return false;
148+
}
149+
150+
@Override
151+
public String getName() {
152+
return "Scene";
153+
}
154+
155+
@Override
156+
public boolean mergeable(AbstractComponent<?> other) {
157+
return other instanceof Scene;
158+
}
159+
160+
@Override
161+
public boolean merge(AbstractComponent<?> other) {
162+
Scene newScene = (Scene) other;
163+
Configuration newConfiguration = mergeChannelConfiguration(channel, newScene);
164+
165+
addScene(newScene);
166+
167+
// Recreate the channel so that the configuration will have all the scenes
168+
stop();
169+
channel = buildChannel(SCENE_CHANNEL_ID, ComponentChannelType.STRING, value, "Scene",
170+
componentConfiguration.getUpdateListener())
171+
.withConfiguration(newConfiguration)
172+
.commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
173+
channelConfiguration.getQos())
174+
.commandFilter(this::handleCommand).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build();
175+
// New ChannelState created; need to make sure we're referencing the correct one
176+
topicsToChannelStates.put(channelConfiguration.commandTopic, channel.getState());
177+
return true;
60178
}
61179
}

bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/config/dto/AbstractChannelConfiguration.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public abstract class AbstractChannelConfiguration {
3535
public static final char PARENT_TOPIC_PLACEHOLDER = '~';
3636
private static final String DEFAULT_THING_NAME = "Home Assistant Device";
3737

38-
protected @Nullable String name;
38+
protected String name;
3939

4040
protected String icon = "";
4141
protected int qos; // defaults to 0 according to HA specification
@@ -136,7 +136,7 @@ public Map<String, Object> appendToProperties(Map<String, Object> properties) {
136136
return properties;
137137
}
138138

139-
public @Nullable String getName() {
139+
public String getName() {
140140
return name;
141141
}
142142

bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/handler/HomeAssistantThingHandler.java

+12-27
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelLinkageChecker;
4343
import org.openhab.binding.mqtt.homeassistant.internal.component.AbstractComponent;
4444
import org.openhab.binding.mqtt.homeassistant.internal.component.ComponentFactory;
45-
import org.openhab.binding.mqtt.homeassistant.internal.component.DeviceTrigger;
4645
import org.openhab.binding.mqtt.homeassistant.internal.component.Update;
4746
import org.openhab.binding.mqtt.homeassistant.internal.config.ChannelConfigurationTypeAdapterFactory;
4847
import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException;
@@ -482,33 +481,19 @@ private void releaseStateUpdated(Update.ReleaseState state) {
482481
private boolean addComponent(AbstractComponent<?> component) {
483482
AbstractComponent<?> existing = haComponents.get(component.getComponentId());
484483
if (existing != null) {
485-
// DeviceTriggers that are for the same subtype, topic, and value template
486-
// can be coalesced together
487-
if (component instanceof DeviceTrigger newTrigger && existing instanceof DeviceTrigger oldTrigger
488-
&& newTrigger.getChannelConfiguration().getSubtype()
489-
.equals(oldTrigger.getChannelConfiguration().getSubtype())
490-
&& newTrigger.getChannelConfiguration().getTopic()
491-
.equals(oldTrigger.getChannelConfiguration().getTopic())
492-
&& oldTrigger.getHaID().nodeID.equals(newTrigger.getHaID().nodeID)) {
493-
String newTriggerValueTemplate = newTrigger.getChannelConfiguration().getValueTemplate();
494-
String oldTriggerValueTemplate = oldTrigger.getChannelConfiguration().getValueTemplate();
495-
if ((newTriggerValueTemplate == null && oldTriggerValueTemplate == null)
496-
|| (newTriggerValueTemplate != null & oldTriggerValueTemplate != null
497-
&& newTriggerValueTemplate.equals(oldTriggerValueTemplate))) {
498-
// Adjust the set of valid values
499-
MqttBrokerConnection connection = this.connection;
500-
501-
if (oldTrigger.merge(newTrigger) && connection != null) {
502-
// Make sure to re-start if this did something, and it was stopped
503-
oldTrigger.start(connection, scheduler, 0).exceptionally(e -> {
504-
logger.warn("Failed to start component {}", oldTrigger.getHaID(), e);
505-
return null;
506-
});
507-
}
508-
haComponentsByUniqueId.put(component.getUniqueId(), component);
509-
haComponentsByHaId.put(component.getHaID(), component);
510-
return false;
484+
// Check for components that merge together
485+
if (component.mergeable(existing)) {
486+
MqttBrokerConnection connection = this.connection;
487+
if (existing.merge(component) && connection != null) {
488+
// Make sure to re-start if this did something, and it was stopped
489+
existing.start(connection, scheduler, 0).exceptionally(e -> {
490+
logger.warn("Failed to start component {}", existing.getHaID(), e);
491+
return null;
492+
});
511493
}
494+
haComponentsByUniqueId.put(component.getUniqueId(), component);
495+
haComponentsByHaId.put(component.getHaID(), component);
496+
return false;
512497
}
513498

514499
// rename the conflict

0 commit comments

Comments
 (0)