From 436dea619d210704cb4ed326b7805cb33bb6fa8e Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Tue, 10 Sep 2024 14:37:05 -0600 Subject: [PATCH] [mqtt.homeassistant] Implement template schema lights (#17399) * [mqtt.homeassistant] implement template schema lights Signed-off-by: Cody Cutrer --- .../HomeAssistantChannelTransformation.java | 11 +- .../component/AbstractRawSchemaLight.java | 2 + .../internal/component/JSONSchemaLight.java | 3 - .../internal/component/Light.java | 2 + .../component/TemplateSchemaLight.java | 310 ++++++++++++++++++ .../component/TemplateSchemaLightTests.java | 204 ++++++++++++ 6 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLightTests.java diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java index 3a51c77bf2d..50731305faa 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java @@ -63,7 +63,10 @@ public class HomeAssistantChannelTransformation extends ChannelTransformation { @Override public Optional apply(String value) { - String transformationResult; + return apply(template, value); + } + + public Optional apply(String template, String value) { Map bindings = new HashMap<>(); logger.debug("about to transform '{}' by the function '{}'", value, template); @@ -77,6 +80,12 @@ public class HomeAssistantChannelTransformation extends ChannelTransformation { // ok, then value_json is null... } + return apply(template, bindings); + } + + public Optional apply(String template, Map bindings) { + String transformationResult; + try { transformationResult = jinjava.render(template, bindings); } catch (FatalTemplateErrorsException e) { diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java index c7a43e57bbd..db7241130c3 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java @@ -31,6 +31,7 @@ abstract class AbstractRawSchemaLight extends Light { protected static final String RAW_CHANNEL_ID = "raw"; protected ComponentChannel rawChannel; + protected TextValue colorModeValue; public AbstractRawSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) { super(builder, newStyleChannels); @@ -39,6 +40,7 @@ abstract class AbstractRawSchemaLight extends Light { .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(), channelConfiguration.getQos()) .build(false)); + colorModeValue = new TextValue(); } protected boolean handleCommand(Command command) { 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 4895e6cdccc..ac218c337a8 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 @@ -71,11 +71,8 @@ public class JSONSchemaLight extends AbstractRawSchemaLight { protected @Nullable Integer transition; } - TextValue colorModeValue; - public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) { super(builder, newStyleChannels); - colorModeValue = new TextValue(); } @Override diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java index b0de155f905..ca47f7e3df3 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java @@ -251,6 +251,8 @@ public abstract class Light extends AbstractComponent handleCommand(command)).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(); + } else { + onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, ComponentChannelType.SWITCH, onOffValue, "On/Off State", + this).commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> handleCommand(command)).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(); + } + 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(); + } + } + + private static BigDecimal factor = new BigDecimal("2.55"); // string to not lose precision + + @Override + protected void publishState(HSBType state) { + Map binding = new HashMap<>(); + String template; + + logger.trace("Publishing new state {} of light {} to MQTT.", state, getName()); + if (state.getBrightness().equals(PercentType.ZERO)) { + template = Objects.requireNonNull(channelConfiguration.commandOffTemplate); + binding.put(TemplateVariables.STATE, "off"); + } else { + template = Objects.requireNonNull(channelConfiguration.commandOnTemplate); + binding.put(TemplateVariables.STATE, "on"); + if (channelConfiguration.brightnessTemplate != null) { + binding.put(TemplateVariables.BRIGHTNESS, + state.getBrightness().toBigDecimal().multiply(factor).intValue()); + } + if (hasColorChannel) { + int[] rgb = ColorUtil.hsbToRgb(state); + binding.put(TemplateVariables.RED, rgb[0]); + binding.put(TemplateVariables.GREEN, rgb[1]); + binding.put(TemplateVariables.BLUE, rgb[2]); + binding.put(TemplateVariables.HUE, state.getHue().toBigDecimal()); + binding.put(TemplateVariables.SAT, state.getSaturation().toBigDecimal()); + } + } + + publishState(binding, template); + } + + private boolean handleColorTempCommand(Command command) { + if (command instanceof DecimalType) { + command = new QuantityType<>(((DecimalType) command).toBigDecimal(), Units.MIRED); + } + if (command instanceof QuantityType quantity) { + QuantityType mireds = quantity.toInvertibleUnit(Units.MIRED); + if (mireds == null) { + logger.warn("Unable to convert {} to mireds", command); + return false; + } + + Map binding = new HashMap<>(); + + binding.put(TemplateVariables.STATE, "on"); + binding.put(TemplateVariables.COLOR_TEMP, mireds.toBigDecimal().intValue()); + + publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate)); + } + return false; + } + + private boolean handleEffectCommand(Command command) { + if (!(command instanceof StringType)) { + return false; + } + + Map binding = new HashMap<>(); + + binding.put(TemplateVariables.STATE, "on"); + binding.put(TemplateVariables.EFFECT, command.toString()); + + publishState(binding, Objects.requireNonNull(channelConfiguration.commandOnTemplate)); + return false; + } + + private void publishState(Map binding, String template) { + String command; + + command = transform(template, binding); + if (command == null) { + return; + } + + logger.debug("Publishing new state '{}' of light {} to MQTT.", command, getHaID().toShortTopic()); + rawChannel.getState().publishValue(new StringType(command)); + } + + @Override + public void updateChannelState(ChannelUID channel, State state) { + ChannelStateUpdateListener listener = this.channelStateUpdateListener; + + String value; + + String template = channelConfiguration.stateTemplate; + if (template != null) { + value = transform(template, state.toString()); + if (value == null || value.isEmpty()) { + onOffValue.update(UnDefType.NULL); + } else if ("on".equals(value)) { + onOffValue.update(OnOffType.ON); + } else if ("off".equals(value)) { + onOffValue.update(OnOffType.OFF); + } else { + logger.warn("Invalid state value '{}' for component {}; expected 'on' or 'off'.", value, + getHaID().toShortTopic()); + onOffValue.update(UnDefType.UNDEF); + } + if (brightnessValue.getChannelState() instanceof UnDefType + && !(onOffValue.getChannelState() instanceof UnDefType)) { + brightnessValue.update( + (PercentType) Objects.requireNonNull(onOffValue.getChannelState().as(PercentType.class))); + } + if (colorValue.getChannelState() instanceof UnDefType) { + colorValue.update((OnOffType) onOffValue.getChannelState()); + } + } + + template = channelConfiguration.brightnessTemplate; + if (template != null) { + Integer brightness = getColorChannelValue(template, state.toString()); + if (brightness == null) { + brightnessValue.update(UnDefType.NULL); + colorValue.update(UnDefType.NULL); + } else { + brightnessValue.update((PercentType) brightnessValue.parseMessage(new DecimalType(brightness))); + if (colorValue.getChannelState() instanceof HSBType color) { + colorValue.update(new HSBType(color.getHue(), color.getSaturation(), + (PercentType) brightnessValue.getChannelState())); + } else { + colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO, + (PercentType) brightnessValue.getChannelState())); + } + } + } + + @Nullable + String redTemplate, greenTemplate, blueTemplate; + if ((redTemplate = channelConfiguration.redTemplate) != null + && (greenTemplate = channelConfiguration.greenTemplate) != null + && (blueTemplate = channelConfiguration.blueTemplate) != null) { + Integer red = getColorChannelValue(redTemplate, state.toString()); + Integer green = getColorChannelValue(greenTemplate, state.toString()); + Integer blue = getColorChannelValue(blueTemplate, state.toString()); + if (red == null || green == null || blue == null) { + colorValue.update(UnDefType.NULL); + } else { + colorValue.update(HSBType.fromRGB(red, green, blue)); + } + } + + if (hasColorChannel) { + listener.updateChannelState(buildChannelUID(COLOR_CHANNEL_ID), colorValue.getChannelState()); + } else if (brightnessChannel != null) { + listener.updateChannelState(buildChannelUID(BRIGHTNESS_CHANNEL_ID), brightnessValue.getChannelState()); + } else { + listener.updateChannelState(buildChannelUID(ON_OFF_CHANNEL_ID), onOffValue.getChannelState()); + } + + template = channelConfiguration.effectTemplate; + if (template != null) { + value = transform(template, state.toString()); + if (value == null || value.isEmpty()) { + effectValue.update(UnDefType.NULL); + } else { + effectValue.update(new StringType(value)); + } + listener.updateChannelState(buildChannelUID(EFFECT_CHANNEL_ID), effectValue.getChannelState()); + } + + template = channelConfiguration.colorTempTemplate; + if (template != null) { + Integer mireds = getColorChannelValue(template, state.toString()); + if (mireds == null) { + colorTempValue.update(UnDefType.NULL); + } else { + colorTempValue.update(new QuantityType(mireds, Units.MIRED)); + } + listener.updateChannelState(buildChannelUID(COLOR_TEMP_CHANNEL_ID), colorTempValue.getChannelState()); + } + } + + private @Nullable Integer getColorChannelValue(String template, String value) { + Object result = transform(template, value); + if (result == null) { + return null; + } + + String string = result.toString(); + if (string.isEmpty()) { + return null; + } + try { + return Integer.parseInt(result.toString()); + } catch (NumberFormatException e) { + logger.warn("Applying template {} for component {} failed: {}", template, getHaID().toShortTopic(), + e.getMessage()); + return null; + } + } + + private @Nullable String transform(String template, Map binding) { + return transformation.apply(template, binding).orElse(null); + } + + private @Nullable String transform(String template, String value) { + return transformation.apply(template, value).orElse(null); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLightTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLightTests.java new file mode 100644 index 00000000000..e7e133bfdac --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLightTests.java @@ -0,0 +1,204 @@ +/** + * Copyright (c) 2010-2024 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 java.math.BigDecimal; +import java.math.MathContext; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.mqtt.generic.values.ColorValue; +import org.openhab.binding.mqtt.generic.values.NumberValue; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.PercentageValue; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.UnDefType; + +/** + * Tests for {@link Light} conforming to the template schema + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class TemplateSchemaLightTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt"; + + @Test + public void testRgb() throws InterruptedException { + var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "availability": [ + { + "topic": "zigbee2mqtt/bridge/state" + } + ], + "device": { + "identifiers": [ + "zigbee2mqtt_0x0000000000000000" + ], + "manufacturer": "Lights inc", + "model": "light v1", + "name": "Light", + "sw_version": "Zigbee2MQTT 1.18.2" + }, + "name": "light", + "schema": "template", + "state_topic": "zigbee2mqtt/light/state", + "command_topic": "zigbee2mqtt/light/set/state", + "command_on_template": "{{state}},{{red}},{{green}},{{blue}}", + "command_off_template": "off", + "state_template": "{{value_json.state}}", + "red_template": "{{value_json.r}}", + "green_template": "{{value_json.g}}", + "blue_template": "{{value_json.b}}", + "brightness_template": "{{value_json.brightness}}" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("light")); + + assertChannel(component, Light.COLOR_CHANNEL_ID, "", "dummy", "Color", ColorValue.class); + + publishMessage("zigbee2mqtt/light/state", """ + { "state": "on", "r": 255, "g": 255, "b": 255, "brightness": 255 } + """); + assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE); + + sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE); + assertPublished("zigbee2mqtt/light/set/state", "on,0,0,255"); + + // OnOff commands should route to the correct topic + sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.OFF); + assertPublished("zigbee2mqtt/light/set/state", "off"); + } + + @Test + public void testBrightnessAndOnOff() throws InterruptedException { + var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "name": "light", + "schema": "template", + "state_topic": "zigbee2mqtt/light/state", + "command_topic": "zigbee2mqtt/light/set/state", + "command_on_template": "{{state}},{{brightness}}", + "command_off_template": "off", + "state_template": "{{value_json.state}}", + "brightness_template": "{{value_json.brightness}}" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("light")); + + assertChannel(component, Light.BRIGHTNESS_CHANNEL_ID, "", "dummy", "Brightness", PercentageValue.class); + + publishMessage("zigbee2mqtt/light/state", "{ \"state\": \"on\", \"brightness\": 128 }"); + assertState(component, Light.BRIGHTNESS_CHANNEL_ID, + new PercentType(new BigDecimal(128 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128))); + + sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, PercentType.HUNDRED); + assertPublished("zigbee2mqtt/light/set/state", "on,255"); + + sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.OFF); + assertPublished("zigbee2mqtt/light/set/state", "off"); + } + + @Test + public void testBrightnessAndCCT() throws InterruptedException { + var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC), + """ + { + "schema": "template", + "name": "Bulb-white", + "command_topic": "shellies/bulb/color/0/set", + "state_topic": "shellies/bulb/color/0/status", + "availability_topic": "shellies/bulb/online", + "command_on_template": "{\\"turn\\": \\"on\\", \\"mode\\": \\"white\\"{%- if brightness is defined -%}, \\"brightness\\": {{brightness | float | multiply(0.39215686) | round(0)}}{%- endif -%}{%- if color_temp is defined -%}, \\"temp\\": {{ (1000000 / color_temp | float) | round(0) }}{%- endif -%}}", + "command_off_template": "{\\"turn\\":\\"off\\", \\"mode\\": \\"white\\"}", + "state_template": "{% if value_json.ison and value_json.mode == 'white' %}on{% else %}off{% endif %}", + "brightness_template": "{{ value_json.brightness | float | multiply(2.55) | round(0) }}", + "color_temp_template": "{{ (1000000 / value_json.temp | float) | round(0) }}", + "payload_available": "true", + "payload_not_available": "false", + "max_mireds": 334, + "min_mireds": 153, + "qos": 1, + "retain": false, + "optimistic": false + } + """); + + assertThat(component.channels.size(), is(2)); + assertThat(component.getName(), is("Bulb-white")); + + assertChannel(component, Light.BRIGHTNESS_CHANNEL_ID, "", "dummy", "Brightness", PercentageValue.class); + assertChannel(component, Light.COLOR_TEMP_CHANNEL_ID, "", "dummy", "Color Temperature", NumberValue.class); + + publishMessage("shellies/bulb/color/0/status", "{ \"state\": \"on\", \"brightness\": 100 }"); + assertState(component, Light.BRIGHTNESS_CHANNEL_ID, PercentType.HUNDRED); + assertState(component, Light.COLOR_TEMP_CHANNEL_ID, UnDefType.NULL); + + sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, PercentType.HUNDRED); + assertPublished("shellies/bulb/color/0/set", "{\"turn\": \"on\", \"mode\": \"white\", \"brightness\": 100}"); + + sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.OFF); + assertPublished("shellies/bulb/color/0/set", "{\"turn\":\"off\", \"mode\": \"white\"}"); + + sendCommand(component, Light.COLOR_TEMP_CHANNEL_ID, new QuantityType(200, Units.MIRED)); + assertPublished("shellies/bulb/color/0/set", "{\"turn\": \"on\", \"mode\": \"white\", \"temp\": 5000}"); + } + + @Test + public void testOnOffOnly() throws InterruptedException { + var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "name": "light", + "schema": "template", + "state_topic": "zigbee2mqtt/light/state", + "command_topic": "zigbee2mqtt/light/set/state", + "state_template": "{{ value_json.power }}", + "command_on_template": "{{state}}", + "command_off_template": "off" + } + """); + + assertThat(component.channels.size(), is(1)); + assertThat(component.getName(), is("light")); + + assertChannel(component, Light.ON_OFF_CHANNEL_ID, "", "dummy", "On/Off State", OnOffValue.class); + + publishMessage("zigbee2mqtt/light/state", "{\"power\": \"on\"}"); + assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON); + publishMessage("zigbee2mqtt/light/state", "{\"power\": \"off\"}"); + assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF); + + sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF); + assertPublished("zigbee2mqtt/light/set/state", "off"); + sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON); + assertPublished("zigbee2mqtt/light/set/state", "on"); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}