From e1ec05b6f764773980019f8539e67c57393168d9 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Tue, 20 Sep 2022 17:05:14 -0600 Subject: [PATCH] [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 | 313 ++++++++++++++++++ .../component/TemplateSchemaLightTests.java | 204 ++++++++++++ 6 files changed, 531 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 3a51c77bf2d5a..50731305faa03 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 boolean isEmpty() { @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 Optional apply(String value) { // 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 c7a43e57bbd8c..db7241130c30a 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 @@ public AbstractRawSchemaLight(ComponentFactory.ComponentConfiguration builder, b .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 4895e6cdccc58..ac218c337a86d 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 @@ protected static class Color { 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 b0de155f9052a..ca47f7e3df3ee 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 static Light create(ComponentFactory.ComponentConfiguration builder, bool return new DefaultSchemaLight(builder, newStyleChannels); case JSON_SCHEMA: return new JSONSchemaLight(builder, newStyleChannels); + case TEMPLATE_SCHEMA: + return new TemplateSchemaLight(builder, newStyleChannels); default: throw new UnsupportedComponentException( "Component '" + builder.getHaID() + "' of schema '" + schema + "' is not supported!"); 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 new file mode 100644 index 0000000000000..e44d06837e57c --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java @@ -0,0 +1,313 @@ +/** + * Copyright (c) 2010-2022 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 java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.PercentageValue; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType; +import org.openhab.binding.mqtt.homeassistant.internal.HomeAssistantChannelTransformation; +import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException; +import org.openhab.core.library.types.DecimalType; +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.types.StringType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification. + * + * Specifically, the template schema. All channels are synthetic, and wrap the single internal raw + * state. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class TemplateSchemaLight extends AbstractRawSchemaLight { + private final Logger logger = LoggerFactory.getLogger(TemplateSchemaLight.class); + private final HomeAssistantChannelTransformation transformation; + + private static class TemplateVariables { + public static final String STATE = "state"; + public static final String TRANSITION = "transition"; + public static final String BRIGHTNESS = "brightness"; + public static final String COLOR_TEMP = "color_temp"; + public static final String RED = "red"; + public static final String GREEN = "green"; + public static final String BLUE = "blue"; + public static final String HUE = "hue"; + public static final String SAT = "sat"; + public static final String FLASH = "flash"; + public static final String EFFECT = "effect"; + } + + public TemplateSchemaLight(ComponentFactory.ComponentConfiguration builder, boolean newStyleChannels) { + super(builder, newStyleChannels); + transformation = new HomeAssistantChannelTransformation(getJinjava(), this, ""); + } + + @Override + protected void buildChannels() { + 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!"); + } + + onOffValue = new OnOffValue("on", "off"); + brightnessValue = new PercentageValue(null, new BigDecimal(255), null, null, null); + + if (channelConfiguration.redTemplate != null && channelConfiguration.greenTemplate != null + && channelConfiguration.blueTemplate != null) { + hasColorChannel = true; + buildChannel(COLOR_CHANNEL_ID, ComponentChannelType.COLOR, colorValue, "Color", this) + .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(command -> 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) { + PercentType[] rgb = state.toRGB(); + binding.put(TemplateVariables.RED, rgb[0].toBigDecimal().multiply(factor).intValue()); + binding.put(TemplateVariables.GREEN, rgb[1].toBigDecimal().multiply(factor).intValue()); + binding.put(TemplateVariables.BLUE, rgb[2].toBigDecimal().multiply(factor).intValue()); + 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) { + QuantityType mireds = ((QuantityType) command).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 (value.equals("on")) { + onOffValue.update(OnOffType.ON); + } else if (value.equals("off")) { + 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) { + HSBType color = (HSBType) colorValue.getChannelState(); + 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) { + Optional result = transformation.apply(template, binding); + return result.orElse(null); + } + + private @Nullable String transform(String template, String value) { + Optional result = transformation.apply(template, value); + return result.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 0000000000000..123932e02b1ea --- /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-2022 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); + } +}