]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] Implement template schema lights (#17399)
authorCody Cutrer <cody@cutrer.us>
Tue, 10 Sep 2024 20:37:05 +0000 (14:37 -0600)
committerGitHub <noreply@github.com>
Tue, 10 Sep 2024 20:37:05 +0000 (22:37 +0200)
* [mqtt.homeassistant] implement template schema lights

Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelTransformation.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLight.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/TemplateSchemaLightTests.java [new file with mode: 0644]

index 3a51c77bf2d5af6834fd6292b8c8b7e72dec98e0..50731305faa03004fc90e37ec80e9757ca33c752 100644 (file)
@@ -63,7 +63,10 @@ public class HomeAssistantChannelTransformation extends ChannelTransformation {
 
     @Override
     public Optional<String> apply(String value) {
-        String transformationResult;
+        return apply(template, value);
+    }
+
+    public Optional<String> apply(String template, String value) {
         Map<String, @Nullable Object> 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<String> apply(String template, Map<String, @Nullable Object> bindings) {
+        String transformationResult;
+
         try {
             transformationResult = jinjava.render(template, bindings);
         } catch (FatalTemplateErrorsException e) {
index c7a43e57bbd8ce3a9bb9225989751b3324016a17..db7241130c30a456bc6f2fa55dea42edcf5741ee 100644 (file)
@@ -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) {
index 4895e6cdccc58b729a54cc9a18eaa29bcb0d08e1..ac218c337a86d751493d9266fd0ba93426b9205e 100644 (file)
@@ -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
index b0de155f9052a2e6506813d29d4ed86463749b23..ca47f7e3df3eecb204e468f006bd23b49f3fe044 100644 (file)
@@ -251,6 +251,8 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
                 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 (file)
index 0000000..89e46bd
--- /dev/null
@@ -0,0 +1,310 @@
+/**
+ * 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 java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+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.openhab.core.util.ColorUtil;
+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<String, @Nullable Object> 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<String, @Nullable Object> 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<String, @Nullable Object> 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<String, @Nullable Object> 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<String, @Nullable Object> 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 (file)
index 0000000..e7e133b
--- /dev/null
@@ -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<String> getConfigTopics() {
+        return Set.of(CONFIG_TOPIC);
+    }
+}