From ab59bc871d152d6383f7ec02941c380d37225f68 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Tue, 8 Oct 2024 00:45:11 -0600 Subject: [PATCH] [mqtt.homeassistant] Implement optimistic components with AutoUpdatePolicy.RECOMMEND (#17520) Signed-off-by: Cody Cutrer --- .../internal/ComponentChannel.java | 12 ++ .../internal/component/Climate.java | 4 +- .../internal/component/Cover.java | 11 +- .../component/DefaultSchemaLight.java | 17 +-- .../homeassistant/internal/component/Fan.java | 12 +- .../internal/component/JSONSchemaLight.java | 19 ++- .../internal/component/Number.java | 10 +- .../internal/component/Select.java | 10 +- .../internal/component/Switch.java | 10 +- .../component/TemplateSchemaLight.java | 15 ++- .../component/AbstractComponentTests.java | 38 ++++++ .../internal/component/FanTests.java | 114 +++++++++++++++++- .../internal/component/NumberTests.java | 71 ++++++++++- 13 files changed, 290 insertions(+), 53 deletions(-) diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java index ac27112bff1ba..1be08c07bea81 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java @@ -225,6 +225,18 @@ public Builder withFormat(String format) { 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). + public Builder inferOptimistic(@Nullable Boolean optimistic) { + String localStateTopic = stateTopic; + if (optimistic == null && (localStateTopic == null || localStateTopic.isBlank()) + || optimistic != null && optimistic == true) { + this.autoUpdatePolicy = AutoUpdatePolicy.RECOMMEND; + } + return this; + } + public ComponentChannel build() { return build(true); } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java index 1a21945962c48..b843972480925 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Climate.java @@ -94,6 +94,8 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { super("MQTT HVAC"); } + protected @Nullable Boolean optimistic; + @SerializedName("action_template") protected @Nullable String actionTemplate; @SerializedName("action_topic") @@ -297,7 +299,7 @@ private ComponentChannel buildOptionalChannel(String channelId, ComponentChannel .stateTopic(stateTopic, stateTemplate, channelConfiguration.getValueTemplate()) .commandTopic(commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), commandTemplate) - .commandFilter(commandFilter).build(); + .inferOptimistic(channelConfiguration.optimistic).commandFilter(commandFilter).build(); } return null; } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java index f365ec33ead36..cc40947d38333 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Cover.java @@ -22,6 +22,7 @@ import org.openhab.core.library.types.StopMoveType; import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.UpDownType; +import org.openhab.core.thing.type.AutoUpdatePolicy; import com.google.gson.annotations.SerializedName; @@ -48,6 +49,8 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { super("MQTT Cover"); } + protected @Nullable Boolean optimistic; + @SerializedName("state_topic") protected @Nullable String stateTopic; @SerializedName("command_topic") @@ -88,6 +91,12 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { public Cover(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); + boolean optimistic = false; + Boolean localOptimistic = channelConfiguration.optimistic; + if (localOptimistic != null && localOptimistic == true + || channelConfiguration.stateTopic == null && channelConfiguration.positionTopic == null) { + optimistic = true; + } String stateTopic = channelConfiguration.stateTopic; // State can indicate additional information than just @@ -149,7 +158,7 @@ public Cover(ComponentFactory.ComponentConfiguration componentConfiguration, boo return false; } return true; - }).build(); + }).withAutoUpdatePolicy(optimistic ? AutoUpdatePolicy.RECOMMEND : null).build(); finalizeChannels(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java index ef5e12786273a..de960385bb7db 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java @@ -29,6 +29,7 @@ import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.StringType; import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.type.AutoUpdatePolicy; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; @@ -60,13 +61,14 @@ public DefaultSchemaLight(ComponentFactory.ComponentConfiguration builder, boole @Override protected void buildChannels() { + AutoUpdatePolicy autoUpdatePolicy = optimistic ? AutoUpdatePolicy.RECOMMEND : null; ComponentChannel localOnOffChannel; localOnOffChannel = onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State", this) .stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .commandFilter(this::handleRawOnOffCommand).build(false); + .withAutoUpdatePolicy(autoUpdatePolicy).commandFilter(this::handleRawOnOffCommand).build(false); @Nullable ComponentChannel localBrightnessChannel = null; @@ -76,7 +78,8 @@ protected void buildChannels() { .stateTopic(channelConfiguration.brightnessStateTopic, channelConfiguration.brightnessValueTemplate) .commandTopic(channelConfiguration.brightnessCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .withFormat("%.0f").commandFilter(this::handleBrightnessCommand).build(false); + .withAutoUpdatePolicy(autoUpdatePolicy).withFormat("%.0f") + .commandFilter(this::handleBrightnessCommand).build(false); } if (channelConfiguration.whiteCommandTopic != null) { @@ -84,14 +87,14 @@ protected void buildChannels() { "Go directly to white of a specific brightness", this) .commandTopic(channelConfiguration.whiteCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .isAdvanced(true).build(); + .withAutoUpdatePolicy(autoUpdatePolicy).isAdvanced(true).build(); } if (channelConfiguration.colorModeStateTopic != null) { buildChannel(COLOR_MODE_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(), "Current color mode", this) .stateTopic(channelConfiguration.colorModeStateTopic, channelConfiguration.colorModeValueTemplate) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); } if (channelConfiguration.colorTempStateTopic != null || channelConfiguration.colorTempCommandTopic != null) { @@ -99,7 +102,7 @@ protected void buildChannels() { .stateTopic(channelConfiguration.colorTempStateTopic, channelConfiguration.colorTempValueTemplate) .commandTopic(channelConfiguration.colorTempCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); } if (effectValue != null @@ -109,7 +112,7 @@ protected void buildChannels() { .stateTopic(channelConfiguration.effectStateTopic, channelConfiguration.effectValueTemplate) .commandTopic(channelConfiguration.effectCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); } boolean hasColorChannel = false; @@ -170,7 +173,7 @@ protected void buildChannels() { } colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this) .commandTopic(DUMMY_TOPIC, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .commandFilter(this::handleColorCommand).build(); + .commandFilter(this::handleColorCommand).withAutoUpdatePolicy(autoUpdatePolicy).build(); } else if (localBrightnessChannel != null) { hiddenChannels.add(localOnOffChannel); channels.put(BRIGHTNESS_CHANNEL_ID, localBrightnessChannel); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java index 3c8ed4139aba0..6b44ba50a0e22 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Fan.java @@ -57,6 +57,8 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { super("MQTT Fan"); } + protected @Nullable Boolean optimistic; + @SerializedName("state_topic") protected @Nullable String stateTopic; @SerializedName("command_template") @@ -136,6 +138,7 @@ public Fan(ComponentFactory.ComponentConfiguration componentConfiguration, boole .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.commandTemplate) + .inferOptimistic(channelConfiguration.optimistic) .build(channelConfiguration.percentageCommandTopic == null); rawSpeedState = UnDefType.NULL; @@ -152,7 +155,8 @@ public Fan(ComponentFactory.ComponentConfiguration componentConfiguration, boole .stateTopic(channelConfiguration.percentageStateTopic, channelConfiguration.percentageValueTemplate) .commandTopic(channelConfiguration.percentageCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.percentageCommandTemplate) - .commandFilter(this::handlePercentageCommand).build(); + .inferOptimistic(channelConfiguration.optimistic).commandFilter(this::handlePercentageCommand) + .build(); } else { primaryChannel = onOffChannel; speedChannel = null; @@ -167,7 +171,7 @@ public Fan(ComponentFactory.ComponentConfiguration componentConfiguration, boole .stateTopic(channelConfiguration.presetModeStateTopic, channelConfiguration.presetModeValueTemplate) .commandTopic(channelConfiguration.presetModeCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.presetModeCommandTemplate) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); } if (channelConfiguration.oscillationCommandTopic != null) { @@ -179,7 +183,7 @@ public Fan(ComponentFactory.ComponentConfiguration componentConfiguration, boole channelConfiguration.oscillationValueTemplate) .commandTopic(channelConfiguration.oscillationCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.oscillationCommandTemplate) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); } if (channelConfiguration.directionCommandTopic != null) { @@ -189,7 +193,7 @@ public Fan(ComponentFactory.ComponentConfiguration componentConfiguration, boole .stateTopic(channelConfiguration.directionStateTopic, channelConfiguration.directionValueTemplate) .commandTopic(channelConfiguration.directionCommandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.directionCommandTemplate) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); } finalizeChannels(); } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java index 006031478c007..7f5eae561a311 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java @@ -31,6 +31,7 @@ import org.openhab.core.library.types.StringType; import org.openhab.core.library.unit.Units; import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.type.AutoUpdatePolicy; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; @@ -79,6 +80,7 @@ public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean @Override protected void buildChannels() { boolean hasColorChannel = false; + AutoUpdatePolicy autoUpdatePolicy = optimistic ? AutoUpdatePolicy.RECOMMEND : null; List supportedColorModes = channelConfiguration.supportedColorModes; if (supportedColorModes != null) { if (LightColorMode.hasColorChannel(supportedColorModes)) { @@ -88,13 +90,14 @@ protected void buildChannels() { if (supportedColorModes.contains(LightColorMode.COLOR_MODE_COLOR_TEMP)) { buildChannel(COLOR_TEMP_CHANNEL_ID, ComponentChannelType.NUMBER, colorTempValue, "Color Temperature", this).commandTopic(DUMMY_TOPIC, true, 1) - .commandFilter(command -> handleColorTempCommand(command)).build(); + .commandFilter(command -> handleColorTempCommand(command)) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); if (hasColorChannel) { colorModeValue = new TextValue( supportedColorModes.stream().map(LightColorMode::serializedName).toArray(String[]::new)); buildChannel(COLOR_MODE_CHANNEL_ID, ComponentChannelType.STRING, colorModeValue, "Color Mode", this) - .isAdvanced(true).build(); + .withAutoUpdatePolicy(autoUpdatePolicy).isAdvanced(true).build(); } } @@ -102,19 +105,23 @@ protected void buildChannels() { if (hasColorChannel) { colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this) - .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build(); + .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } else if (channelConfiguration.brightness) { brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue, - "Brightness", this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build(); + "Brightness", this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } else { onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State", - this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build(); + this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } if (effectValue != null) { buildChannel(EFFECT_CHANNEL_ID, ComponentChannelType.STRING, Objects.requireNonNull(effectValue), "Lighting Effect", this).commandTopic(DUMMY_TOPIC, true, 1) - .commandFilter(command -> handleEffectCommand(command)).build(); + .commandFilter(command -> handleEffectCommand(command)).withAutoUpdatePolicy(autoUpdatePolicy) + .build(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java index 2912ac9b7cc37..8de66574f32e9 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Number.java @@ -19,7 +19,6 @@ import org.openhab.binding.mqtt.generic.values.NumberValue; 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.types.util.UnitUtils; import com.google.gson.annotations.SerializedName; @@ -73,13 +72,6 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { public Number(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); - boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic - : channelConfiguration.stateTopic.isBlank(); - - if (optimistic && !channelConfiguration.stateTopic.isBlank()) { - throw new ConfigurationException("Component:Number does not support forced optimistic mode"); - } - NumberValue value = new NumberValue(channelConfiguration.min, channelConfiguration.max, channelConfiguration.step, UnitUtils.parseUnit(channelConfiguration.unitOfMeasurement)); @@ -88,7 +80,7 @@ public Number(ComponentFactory.ComponentConfiguration componentConfiguration, bo .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.commandTemplate) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); finalizeChannels(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java index ad650c63117b0..90ad460d033c1 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Select.java @@ -17,7 +17,6 @@ import org.openhab.binding.mqtt.generic.values.TextValue; 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 com.google.gson.annotations.SerializedName; @@ -58,13 +57,6 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { public Select(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); - boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic - : channelConfiguration.stateTopic.isBlank(); - - if (optimistic && !channelConfiguration.stateTopic.isBlank()) { - throw new ConfigurationException("Component:Select does not support forced optimistic mode"); - } - TextValue value = new TextValue(channelConfiguration.options); buildChannel(SELECT_CHANNEL_ID, ComponentChannelType.STRING, value, getName(), @@ -72,7 +64,7 @@ public Select(ComponentFactory.ComponentConfiguration componentConfiguration, bo .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos(), channelConfiguration.commandTemplate) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); finalizeChannels(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java index 0027ffe1c77ef..949cf7a5665a5 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Switch.java @@ -17,7 +17,6 @@ import org.openhab.binding.mqtt.generic.values.OnOffValue; 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 com.google.gson.annotations.SerializedName; @@ -63,13 +62,6 @@ static class ChannelConfiguration extends AbstractChannelConfiguration { public Switch(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); - boolean optimistic = channelConfiguration.optimistic != null ? channelConfiguration.optimistic - : channelConfiguration.stateTopic.isBlank(); - - if (optimistic && !channelConfiguration.stateTopic.isBlank()) { - throw new ConfigurationException("Component:Switch does not support forced optimistic mode"); - } - OnOffValue value = new OnOffValue(channelConfiguration.stateOn, channelConfiguration.stateOff, channelConfiguration.payloadOn, channelConfiguration.payloadOff); @@ -78,7 +70,7 @@ public Switch(ComponentFactory.ComponentConfiguration componentConfiguration, bo .stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) - .build(); + .inferOptimistic(channelConfiguration.optimistic).build(); finalizeChannels(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java index 3a056cb36f8a2..fd4512ff12495 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java @@ -35,6 +35,7 @@ import org.openhab.core.library.types.StringType; import org.openhab.core.library.unit.Units; import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.type.AutoUpdatePolicy; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; @@ -76,6 +77,7 @@ public TemplateSchemaLight(ComponentFactory.ComponentConfiguration builder, bool @Override protected void buildChannels() { + AutoUpdatePolicy autoUpdatePolicy = optimistic ? AutoUpdatePolicy.RECOMMEND : null; if (channelConfiguration.commandOnTemplate == null || channelConfiguration.commandOffTemplate == null) { throw new UnsupportedComponentException("Template schema light component '" + getHaID() + "' does not define command_on_template or command_off_template!"); @@ -87,25 +89,28 @@ protected void buildChannels() { if (channelConfiguration.redTemplate != null && channelConfiguration.greenTemplate != null && channelConfiguration.blueTemplate != null) { colorChannel = buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this) - .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)).build(); + .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } else if (channelConfiguration.brightnessTemplate != null) { brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, ComponentChannelType.DIMMER, brightnessValue, "Brightness", this).commandTopic(DUMMY_TOPIC, true, 1) - .commandFilter(command -> handleCommand(command)).build(); + .commandFilter(command -> handleCommand(command)).withAutoUpdatePolicy(autoUpdatePolicy).build(); } else { onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State", - this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)).build(); + this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } if (channelConfiguration.colorTempTemplate != null) { buildChannel(COLOR_TEMP_CHANNEL_ID, ComponentChannelType.NUMBER, colorTempValue, "Color Temperature", this) .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleColorTempCommand(command)) - .build(); + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } TextValue localEffectValue = effectValue; if (channelConfiguration.effectTemplate != null && localEffectValue != null) { buildChannel(EFFECT_CHANNEL_ID, ComponentChannelType.STRING, localEffectValue, "Effect", this) - .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleEffectCommand(command)).build(); + .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleEffectCommand(command)) + .withAutoUpdatePolicy(autoUpdatePolicy).build(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java index dc7d2b1ef1db9..bbb278d530fbf 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java @@ -45,6 +45,7 @@ import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatusInfo; import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.type.AutoUpdatePolicy; import org.openhab.core.thing.type.ChannelTypeRegistry; import org.openhab.core.types.Command; import org.openhab.core.types.State; @@ -167,6 +168,43 @@ protected static void assertChannel(ComponentChannel stateChannel, String stateT assertThat(stateChannel.getState().getCache(), is(instanceOf(valueClass))); } + /** + * Assert channel topics, label and value class + * + * @param component component + * @param channelId channel + * @param stateTopic state topic or empty string + * @param commandTopic command topic or empty string + * @param label label + * @param valueClass value class + * @param autoUpdatePolicy Auto Update Policy + */ + protected static void assertChannel(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component, + String channelId, String stateTopic, String commandTopic, String label, Class valueClass, + @Nullable AutoUpdatePolicy autoUpdatePolicy) { + var stateChannel = Objects.requireNonNull(component.getChannel(channelId)); + assertChannel(stateChannel, stateTopic, commandTopic, label, valueClass); + } + + /** + * Assert channel topics, label and value class + * + * @param stateChannel channel + * @param stateTopic state topic or empty string + * @param commandTopic command topic or empty string + * @param label label + * @param valueClass value class + * @param autoUpdatePolicy Auto Update Policy + */ + protected static void assertChannel(ComponentChannel stateChannel, String stateTopic, String commandTopic, + String label, Class valueClass, @Nullable AutoUpdatePolicy autoUpdatePolicy) { + assertThat(stateChannel.getChannel().getLabel(), is(label)); + assertThat(stateChannel.getState().getStateTopic(), is(stateTopic)); + assertThat(stateChannel.getState().getCommandTopic(), is(commandTopic)); + assertThat(stateChannel.getState().getCache(), is(instanceOf(valueClass))); + assertThat(stateChannel.getChannel().getAutoUpdatePolicy(), is(autoUpdatePolicy)); + } + /** * Assert channel state * diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java index 5d9b0a2e9ced8..674d71e728c22 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/FanTests.java @@ -27,6 +27,7 @@ import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PercentType; import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.type.AutoUpdatePolicy; import org.openhab.core.types.UnDefType; /** @@ -72,7 +73,7 @@ public void test() throws InterruptedException { assertThat(component.getName(), is("fan")); assertChannel(component, Fan.SWITCH_CHANNEL_ID, "zigbee2mqtt/fan/state", "zigbee2mqtt/fan/set/state", - "On/Off State", OnOffValue.class); + "On/Off State", OnOffValue.class, null); publishMessage("zigbee2mqtt/fan/state", "ON_"); assertState(component, Fan.SWITCH_CHANNEL_ID, OnOffType.ON); @@ -89,6 +90,117 @@ public void test() throws InterruptedException { assertPublished("zigbee2mqtt/fan/set/state", "ON_"); } + @SuppressWarnings("null") + @Test + public void testInferredOptimistic() throws InterruptedException { + // @formatter:off + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), + """ + { \ + "availability": [ \ + { \ + "topic": "zigbee2mqtt/bridge/state" \ + } \ + ], \ + "device": { \ + "identifiers": [ \ + "zigbee2mqtt_0x0000000000000000" \ + ], \ + "manufacturer": "Fans inc", \ + "model": "Fan", \ + "name": "FanBlower", \ + "sw_version": "Zigbee2MQTT 1.18.2" \ + }, \ + "name": "fan", \ + "payload_off": "OFF_", \ + "payload_on": "ON_", \ + "command_topic": "zigbee2mqtt/fan/set/state" + }\ + """); + // @formatter:on + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("fan")); + + assertChannel(component, Fan.SWITCH_CHANNEL_ID, "", "zigbee2mqtt/fan/set/state", "On/Off State", + OnOffValue.class, AutoUpdatePolicy.RECOMMEND); + } + + @SuppressWarnings("null") + @Test + public void testForcedOptimistic() throws InterruptedException { + // @formatter:off + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), + """ + { \ + "availability": [ \ + { \ + "topic": "zigbee2mqtt/bridge/state" \ + } \ + ], \ + "device": { \ + "identifiers": [ \ + "zigbee2mqtt_0x0000000000000000" \ + ], \ + "manufacturer": "Fans inc", \ + "model": "Fan", \ + "name": "FanBlower", \ + "sw_version": "Zigbee2MQTT 1.18.2" \ + }, \ + "name": "fan", \ + "payload_off": "OFF_", \ + "payload_on": "ON_", \ + "state_topic": "zigbee2mqtt/fan/state", \ + "command_topic": "zigbee2mqtt/fan/set/state", \ + "optimistic": true \ + }\ + """); + // @formatter:on + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("fan")); + + assertChannel(component, Fan.SWITCH_CHANNEL_ID, "zigbee2mqtt/fan/state", "zigbee2mqtt/fan/set/state", + "On/Off State", OnOffValue.class, AutoUpdatePolicy.RECOMMEND); + } + + @SuppressWarnings("null") + @Test + public void testInferredOptimisticWithPosition() throws InterruptedException { + // @formatter:off + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), + """ + { \ + "availability": [ \ + { \ + "topic": "zigbee2mqtt/bridge/state" \ + } \ + ], \ + "device": { \ + "identifiers": [ \ + "zigbee2mqtt_0x0000000000000000" \ + ], \ + "manufacturer": "Fans inc", \ + "model": "Fan", \ + "name": "FanBlower", \ + "sw_version": "Zigbee2MQTT 1.18.2" \ + }, \ + "name": "fan", \ + "payload_off": "OFF_", \ + "payload_on": "ON_", \ + "command_topic": "zigbee2mqtt/fan/set/state", \ + "percentage_command_topic": "bedroom_fan/speed/percentage" \ + }\ + """); + // @formatter:on + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("fan")); + + assertChannel(component, Fan.SPEED_CHANNEL_ID, "", "bedroom_fan/speed/percentage", "Speed", + PercentageValue.class, AutoUpdatePolicy.RECOMMEND); + } + @SuppressWarnings("null") @Test public void testCommandTemplate() throws InterruptedException { diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java index 678040424f289..e960d335f76c5 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/NumberTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test; import org.openhab.binding.mqtt.generic.values.NumberValue; import org.openhab.core.library.types.DecimalType; +import org.openhab.core.thing.type.AutoUpdatePolicy; /** * Tests for {@link Number} @@ -62,7 +63,7 @@ public void test() throws InterruptedException { assertThat(component.getName(), is("BWA Link Hot Tub Pump 1")); assertChannel(component, Number.NUMBER_CHANNEL_ID, "homie/bwa/spa/pump1", "homie/bwa/spa/pump1/set", - "BWA Link Hot Tub Pump 1", NumberValue.class); + "BWA Link Hot Tub Pump 1", NumberValue.class, null); publishMessage("homie/bwa/spa/pump1", "1"); assertState(component, Number.NUMBER_CHANNEL_ID, new DecimalType(1)); @@ -73,6 +74,74 @@ public void test() throws InterruptedException { assertPublished("homie/bwa/spa/pump1/set", "1"); } + @SuppressWarnings("null") + @Test + public void testInferredOptimistic() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "name": "BWA Link Hot Tub Pump 1", + "availability_topic": "homie/bwa/$state", + "payload_available": "ready", + "payload_not_available": "lost", + "qos": 1, + "icon": "mdi:chart-bubble", + "device": { + "manufacturer": "Balboa Water Group", + "sw_version": "2.1.3", + "model": "BFBP20", + "name": "BWA Link", + "identifiers": "bwa" + }, + "command_topic": "homie/bwa/spa/pump1/set", + "command_template": "{{ value | round(0) }}", + "min": 0, + "max": 2, + "unique_id": "bwa_spa_pump1" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("BWA Link Hot Tub Pump 1")); + + assertChannel(component, Number.NUMBER_CHANNEL_ID, "", "homie/bwa/spa/pump1/set", "BWA Link Hot Tub Pump 1", + NumberValue.class, AutoUpdatePolicy.RECOMMEND); + } + + @SuppressWarnings("null") + @Test + public void testForcedOptimistic() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "name": "BWA Link Hot Tub Pump 1", + "availability_topic": "homie/bwa/$state", + "payload_available": "ready", + "payload_not_available": "lost", + "qos": 1, + "icon": "mdi:chart-bubble", + "device": { + "manufacturer": "Balboa Water Group", + "sw_version": "2.1.3", + "model": "BFBP20", + "name": "BWA Link", + "identifiers": "bwa" + }, + "state_topic": "homie/bwa/spa/pump1", + "command_topic": "homie/bwa/spa/pump1/set", + "command_template": "{{ value | round(0) }}", + "min": 0, + "max": 2, + "unique_id": "bwa_spa_pump1", + "optimistic": true + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("BWA Link Hot Tub Pump 1")); + + assertChannel(component, Number.NUMBER_CHANNEL_ID, "homie/bwa/spa/pump1", "homie/bwa/spa/pump1/set", + "BWA Link Hot Tub Pump 1", NumberValue.class, AutoUpdatePolicy.RECOMMEND); + } + @Override protected Set getConfigTopics() { return Set.of(CONFIG_TOPIC);