Skip to content

Commit

Permalink
[mqtt.homeassistant] Use a single channel for all scenes on a device
Browse files Browse the repository at this point in the history
It accepts either object ID, or scene name (assuming the latter doesn't
conflict with the former). Command descriptions are fully populated.
You also no longer need to deal with sending the payload_on.

Merging components is refactored slightly, now that multiple component
types do it.

Signed-off-by: Cody Cutrer <[email protected]>
  • Loading branch information
ccutrer committed Feb 13, 2025
1 parent 5a57b9e commit 17107cb
Show file tree
Hide file tree
Showing 6 changed files with 369 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,6 +42,29 @@
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
*/
Expand All @@ -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<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");
}

// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -136,7 +136,7 @@ public Map<String, Object> appendToProperties(Map<String, Object> properties) {
return properties;
}

public @Nullable String getName() {
public String getName() {
return name;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 17107cb

Please sign in to comment.