]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] Implement JSON schema lights (#13808)
authorCody Cutrer <cody@cutrer.us>
Sun, 26 Mar 2023 16:07:20 +0000 (10:07 -0600)
committerGitHub <noreply@github.com>
Sun, 26 Mar 2023 16:07:20 +0000 (18:07 +0200)
* [mqtt.homeassistant] implement JSON schema lights
* [mqtt.homeassistant] use enum for current state of color mode
* [mqtt.homeassistant] use implicit lambdas
* [mqtt.homeassistant] remove string constants in favor of an enum
* [mqtt.homeassistant] allow sending ON and brightness commands through bare
* [mqtt.homeassistant] turn down debug logging

---------

Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractRawSchemaLight.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLight.java [new file with mode: 0644]
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/LightColorMode.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java [new file with mode: 0644]

index d4ab2251cbc75021f258e05d307a4c22e6be7275..203218baa75aeed676c39b3431294a5b275c8384 100644 (file)
@@ -39,6 +39,8 @@ import org.openhab.core.thing.type.ChannelGroupType;
 import org.openhab.core.thing.type.ChannelGroupTypeBuilder;
 import org.openhab.core.thing.type.ChannelGroupTypeUID;
 
+import com.google.gson.Gson;
+
 /**
  * A HomeAssistant component is comparable to a channel group.
  * It has a name and consists of multiple channels.
@@ -243,4 +245,8 @@ public abstract class AbstractComponent<C extends AbstractChannelConfiguration>
     public boolean isEnabledByDefault() {
         return channelConfiguration.isEnabledByDefault();
     }
+
+    public Gson getGson() {
+        return componentConfiguration.getGson();
+    }
 }
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
new file mode 100644 (file)
index 0000000..728c25e
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2010-2023 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 org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+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.types.Command;
+
+/**
+ * A base class for common elements between JSON schema and template schema lights.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+abstract class AbstractRawSchemaLight extends Light {
+    protected static final String RAW_CHANNEL_ID = "raw";
+
+    protected ComponentChannel rawChannel;
+
+    public AbstractRawSchemaLight(ComponentFactory.ComponentConfiguration builder) {
+        super(builder);
+        hiddenChannels.add(rawChannel = buildChannel(RAW_CHANNEL_ID, new TextValue(), "Raw state", this)
+                .stateTopic(channelConfiguration.stateTopic).commandTopic(channelConfiguration.commandTopic,
+                        channelConfiguration.isRetain(), channelConfiguration.getQos())
+                .build(false));
+    }
+
+    protected boolean handleCommand(Command command) {
+        HSBType newState;
+        if (colorValue.getChannelState() instanceof HSBType) {
+            newState = (HSBType) colorValue.getChannelState();
+        } else {
+            newState = HSBType.WHITE;
+        }
+
+        if (command.equals(PercentType.ZERO) || command.equals(OnOffType.OFF)) {
+            newState = HSBType.BLACK;
+        } else if (command.equals(OnOffType.ON)) {
+            if (newState.getBrightness().equals(PercentType.ZERO)) {
+                newState = new HSBType(newState.getHue(), newState.getSaturation(), PercentType.HUNDRED);
+            }
+        } else if (command instanceof HSBType) {
+            newState = (HSBType) command;
+        } else if (command instanceof PercentType) {
+            newState = new HSBType(newState.getHue(), newState.getSaturation(), (PercentType) command);
+        } else {
+            return false;
+        }
+
+        publishState(newState);
+        return false;
+    }
+
+    protected abstract void publishState(HSBType state);
+}
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
new file mode 100644 (file)
index 0000000..722af0b
--- /dev/null
@@ -0,0 +1,234 @@
+/**
+ * Copyright (c) 2010-2023 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.math.MathContext;
+import java.util.List;
+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.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.StringType;
+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;
+
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
+ *
+ * Specifically, the JSON schema. All channels are synthetic, and wrap the single internal raw
+ * state.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public class JSONSchemaLight extends AbstractRawSchemaLight {
+    private static final BigDecimal SCALE_FACTOR = new BigDecimal("2.55"); // string to not lose precision
+
+    private final Logger logger = LoggerFactory.getLogger(JSONSchemaLight.class);
+
+    private static class JSONState {
+        protected static class Color {
+            protected @Nullable Integer r, g, b, c, w;
+            protected @Nullable BigDecimal x, y, h, s;
+        }
+
+        protected @Nullable String state;
+        protected @Nullable Integer brightness;
+        @SerializedName("color_mode")
+        protected @Nullable LightColorMode colorMode;
+        @SerializedName("color_temp")
+        protected @Nullable Integer colorTemp;
+        protected @Nullable Color color;
+        protected @Nullable String effect;
+        protected @Nullable Integer transition;
+    }
+
+    public JSONSchemaLight(ComponentFactory.ComponentConfiguration builder) {
+        super(builder);
+    }
+
+    @Override
+    protected void buildChannels() {
+        if (channelConfiguration.colorMode) {
+            List<LightColorMode> supportedColorModes = channelConfiguration.supportedColorModes;
+            if (supportedColorModes == null || channelConfiguration.supportedColorModes.isEmpty()) {
+                throw new UnsupportedComponentException("JSON schema light with color modes '" + getHaID()
+                        + "' does not define supported_color_modes!");
+            }
+
+            if (LightColorMode.hasColorChannel(supportedColorModes)) {
+                hasColorChannel = true;
+            }
+        }
+
+        if (hasColorChannel) {
+            buildChannel(COLOR_CHANNEL_ID, colorValue, "Color", this).commandTopic(DUMMY_TOPIC, true, 1)
+                    .commandFilter(this::handleCommand).build();
+        } else if (channelConfiguration.brightness) {
+            brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, brightnessValue, "Brightness", this)
+                    .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build();
+        } else {
+            onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, onOffValue, "On/Off State", this)
+                    .commandTopic(DUMMY_TOPIC, true, 1).commandFilter(this::handleCommand).build();
+        }
+    }
+
+    @Override
+    protected void publishState(HSBType state) {
+        JSONState json = new JSONState();
+
+        logger.trace("Publishing new state {} of light {} to MQTT.", state, getName());
+        if (state.getBrightness().equals(PercentType.ZERO)) {
+            json.state = "OFF";
+        } else {
+            json.state = "ON";
+            if (channelConfiguration.brightness || (channelConfiguration.supportedColorModes != null
+                    && (channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_HS)
+                            || channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_XY)))) {
+                json.brightness = state.getBrightness().toBigDecimal()
+                        .multiply(new BigDecimal(channelConfiguration.brightnessScale))
+                        .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
+            }
+
+            if (hasColorChannel) {
+                json.color = new JSONState.Color();
+                if (channelConfiguration.supportedColorModes.contains(LightColorMode.COLOR_MODE_HS)) {
+                    json.color.h = state.getHue().toBigDecimal();
+                    json.color.s = state.getSaturation().toBigDecimal();
+                } else if (LightColorMode.hasRGB(Objects.requireNonNull(channelConfiguration.supportedColorModes))) {
+                    var rgb = state.toRGB();
+                    json.color.r = rgb[0].toBigDecimal().multiply(SCALE_FACTOR).intValue();
+                    json.color.g = rgb[1].toBigDecimal().multiply(SCALE_FACTOR).intValue();
+                    json.color.b = rgb[2].toBigDecimal().multiply(SCALE_FACTOR).intValue();
+                } else { // if (channelConfiguration.supportedColorModes.contains(COLOR_MODE_XY))
+                    var xy = state.toXY();
+                    json.color.x = xy[0].toBigDecimal();
+                    json.color.y = xy[1].toBigDecimal();
+                }
+            }
+        }
+
+        String command = getGson().toJson(json);
+        logger.debug("Publishing new state '{}' of light {} to MQTT.", command, getName());
+        rawChannel.getState().publishValue(new StringType(command));
+    }
+
+    protected boolean handleCommand(Command command) {
+        JSONState json = new JSONState();
+        if (command.getClass().equals(OnOffType.class)) {
+            json.state = command.toString();
+        } else if (command.getClass().equals(PercentType.class)) {
+            if (command.equals(PercentType.ZERO)) {
+                json.state = "OFF";
+            } else {
+                json.state = "ON";
+                if (channelConfiguration.brightness) {
+                    json.brightness = ((PercentType) command).toBigDecimal()
+                            .multiply(new BigDecimal(channelConfiguration.brightnessScale))
+                            .divide(new BigDecimal(100), MathContext.DECIMAL128).intValue();
+                }
+            }
+        } else {
+            return super.handleCommand(command);
+        }
+
+        String jsonCommand = getGson().toJson(json);
+        logger.debug("Publishing new state '{}' of light {} to MQTT.", jsonCommand, getName());
+        rawChannel.getState().publishValue(new StringType(jsonCommand));
+        return false;
+    }
+
+    @Override
+    public void updateChannelState(ChannelUID channel, State state) {
+        ChannelStateUpdateListener listener = this.channelStateUpdateListener;
+
+        @Nullable
+        JSONState jsonState;
+        try {
+            jsonState = getGson().fromJson(state.toString(), JSONState.class);
+
+            if (jsonState == null) {
+                logger.warn("JSON light state for '{}' is empty.", getHaID());
+                return;
+            }
+        } catch (JsonSyntaxException e) {
+            logger.warn("Cannot parse JSON light state '{}' for '{}'.", state, getHaID());
+            return;
+        }
+
+        if (jsonState.state != null) {
+            onOffValue.update(new StringType(jsonState.state));
+            if (brightnessValue.getChannelState() instanceof UnDefType) {
+                brightnessValue.update((OnOffType) onOffValue.getChannelState());
+            }
+            if (colorValue.getChannelState() instanceof UnDefType) {
+                colorValue.update((OnOffType) onOffValue.getChannelState());
+            }
+        }
+
+        if (jsonState.brightness != null) {
+            brightnessValue.update(new DecimalType(Objects.requireNonNull(jsonState.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()));
+            }
+        }
+
+        if (jsonState.color != null) {
+            PercentType brightness = brightnessValue.getChannelState() instanceof PercentType
+                    ? (PercentType) brightnessValue.getChannelState()
+                    : PercentType.HUNDRED;
+            // This corresponds to "deprecated" color mode handling, since we're not checking which color
+            // mode is currently active.
+            // HS is highest priority, then XY, then RGB
+            // See
+            // https://github.com/home-assistant/core/blob/4f965f0eca09f0d12ae1c98c6786054063a36b44/homeassistant/components/mqtt/light/schema_json.py#L258
+            if (jsonState.color.h != null && jsonState.color.s != null) {
+                colorValue.update(new HSBType(new DecimalType(Objects.requireNonNull(jsonState.color.h)),
+                        new PercentType(Objects.requireNonNull(jsonState.color.s)), brightness));
+            } else if (jsonState.color.x != null && jsonState.color.y != null) {
+                HSBType newColor = HSBType.fromXY(jsonState.color.x.floatValue(), jsonState.color.y.floatValue());
+                colorValue.update(new HSBType(newColor.getHue(), newColor.getSaturation(), brightness));
+            } else if (jsonState.color.r != null && jsonState.color.g != null && jsonState.color.b != null) {
+                colorValue.update(HSBType.fromRGB(jsonState.color.r, jsonState.color.g, jsonState.color.b));
+            }
+        }
+
+        if (hasColorChannel) {
+            listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID), colorValue.getChannelState());
+        } else if (brightnessChannel != null) {
+            listener.updateChannelState(new ChannelUID(getGroupUID(), BRIGHTNESS_CHANNEL_ID),
+                    brightnessValue.getChannelState());
+        } else {
+            listener.updateChannelState(new ChannelUID(getGroupUID(), ON_OFF_CHANNEL_ID), onOffValue.getChannelState());
+        }
+    }
+}
index f389d3771bb3221a47c8aebe855ecd32021ae281..782268cdab047c1b60fc745760818eb3274acf54 100644 (file)
@@ -92,7 +92,7 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
         @SerializedName("color_mode")
         protected boolean colorMode = false; // JSON schema only
         @SerializedName("supported_color_modes")
-        protected @Nullable List<String> supportedColorModes; // JSON schema only
+        protected @Nullable List<LightColorMode> supportedColorModes; // JSON schema only
         // Defines when on the payload_on is sent. Using last (the default) will send
         // any style (brightness, color, etc)
         // topics first and then a payload_on to the command_topic. Using first will
@@ -257,6 +257,8 @@ public abstract class Light extends AbstractComponent<Light.ChannelConfiguration
         switch (schema) {
             case DEFAULT_SCHEMA:
                 return new DefaultSchemaLight(builder);
+            case JSON_SCHEMA:
+                return new JSONSchemaLight(builder);
             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/LightColorMode.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightColorMode.java
new file mode 100644 (file)
index 0000000..cb8aa6f
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2010-2023 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.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The types of color modes a JSONSchemaLight can support.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public enum LightColorMode {
+    @SerializedName("onoff")
+    COLOR_MODE_ONOFF,
+    @SerializedName("brightness")
+    COLOR_MODE_BRIGHTNESS,
+    @SerializedName("color_temp")
+    COLOR_MODE_COLOR_TEMP,
+    @SerializedName("hs")
+    COLOR_MODE_HS,
+    @SerializedName("xy")
+    COLOR_MODE_XY,
+    @SerializedName("rgb")
+    COLOR_MODE_RGB,
+    @SerializedName("rgbw")
+    COLOR_MODE_RGBW,
+    @SerializedName("rgbww")
+    COLOR_MODE_RGBWW,
+    @SerializedName("white")
+    COLOR_MODE_WHITE;
+
+    public static final List<LightColorMode> WITH_RGB = List.of(COLOR_MODE_RGB, COLOR_MODE_RGBW, COLOR_MODE_RGBWW);
+    public static final List<LightColorMode> WITH_COLOR_CHANNEL = List.of(COLOR_MODE_HS, COLOR_MODE_RGB,
+            COLOR_MODE_RGBW, COLOR_MODE_RGBWW, COLOR_MODE_XY);
+
+    /**
+     * Determines if the list of supported modes includes any that should generate an openHAB Color channel
+     */
+    public static boolean hasColorChannel(List<LightColorMode> supportedColorModes) {
+        return WITH_COLOR_CHANNEL.stream().anyMatch(cm -> supportedColorModes.contains(cm));
+    }
+
+    /**
+     * Determins if the list of supported modes includes any that have RGB components
+     */
+    public static boolean hasRGB(List<LightColorMode> supportedColorModes) {
+        return WITH_RGB.stream().anyMatch(cm -> supportedColorModes.contains(cm));
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/JSONSchemaLightTests.java
new file mode 100644 (file)
index 0000000..a339574
--- /dev/null
@@ -0,0 +1,157 @@
+/**
+ * Copyright (c) 2010-2023 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.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;
+
+/**
+ * Tests for {@link Light} conforming to the JSON schema
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public class JSONSchemaLightTests extends AbstractComponentTests {
+    public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt";
+
+    @Test
+    public void testRgb() throws InterruptedException {
+        // @formatter:off
+        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\": \"json\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"brightness\": true, " +
+                        "  \"color_mode\": true, " +
+                        "  \"supported_color_modes\": [\"onoff\", \"brightness\", \"rgb\"]" +
+                        "}");
+        // @formatter:on
+
+        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\" }");
+        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
+        publishMessage("zigbee2mqtt/light/state", "{ \"color\": {\"r\": 10, \"g\": 20, \"b\": 30 } }");
+        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(10, 20, 30));
+        publishMessage("zigbee2mqtt/light/state", "{ \"brightness\": 255 }");
+        assertState(component, Light.COLOR_CHANNEL_ID, new HSBType("210,66,100"));
+
+        sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE);
+        assertPublished("zigbee2mqtt/light/set/state",
+                "{\"state\":\"ON\",\"brightness\":255,\"color\":{\"r\":0,\"g\":0,\"b\":255}}");
+
+        // OnOff commands should route to the correct topic
+        sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.OFF);
+        assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"OFF\"}");
+
+        sendCommand(component, Light.COLOR_CHANNEL_ID, OnOffType.ON);
+        assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\"}");
+
+        sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
+        assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\",\"brightness\":127}");
+    }
+
+    @Test
+    public void testBrightnessAndOnOff() throws InterruptedException {
+        // @formatter:off
+        var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+                "{ " +
+                        "  \"name\": \"light\", " +
+                        "  \"schema\": \"json\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"brightness\": true" +
+                        "}");
+        // @formatter:on
+
+        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", "{\"state\":\"ON\",\"brightness\":255}");
+
+        sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.OFF);
+        assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"OFF\"}");
+    }
+
+    @Test
+    public void testOnOffOnly() throws InterruptedException {
+        // @formatter:off
+        var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+                "{ " +
+                        "  \"name\": \"light\", " +
+                        "  \"schema\": \"json\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\"" +
+                        "}");
+        // @formatter:on
+
+        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", "{ \"state\": \"ON\" }");
+        assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON);
+        publishMessage("zigbee2mqtt/light/state", "{ \"state\": \"OFF\" }");
+        assertState(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF);
+
+        sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.OFF);
+        assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"OFF\"}");
+        sendCommand(component, Light.ON_OFF_CHANNEL_ID, OnOffType.ON);
+        assertPublished("zigbee2mqtt/light/set/state", "{\"state\":\"ON\"}");
+    }
+
+    @Override
+    protected Set<String> getConfigTopics() {
+        return Set.of(CONFIG_TOPIC);
+    }
+}