]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mqtt.homeassistant] support non-RGB lights (#13413)
authorCody Cutrer <cody@cutrer.us>
Sat, 5 Nov 2022 15:57:06 +0000 (09:57 -0600)
committerGitHub <noreply@github.com>
Sat, 5 Nov 2022 15:57:06 +0000 (16:57 +0100)
* [mqtt.homeassistant] support non-RGB lights

dynamically decide which type of channel to expose. also send "down-typed"
commands to the proper topic. this also sets the groundwork for supporting
template and JSON schemas

Signed-off-by: Cody Cutrer <cody@cutrer.us>
bundles/org.openhab.binding.mqtt.homeassistant/README.md
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java
bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.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/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLightTests.java [new file with mode: 0644]
bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java [deleted file]

index 409a788753570b1fc063625229571d8c0c11d4e2..09dd009cfb6812f32424b60bdae86c529367822e 100644 (file)
@@ -22,7 +22,8 @@ These can be installed under `Settings` &rarr; `Addon` &rarr; `Transformations`
 
 * The HomeAssistant Fan Components only support ON/OFF.
 * The HomeAssistant Cover Components only support OPEN/CLOSE/STOP.
-* The HomeAssistant Light Component only supports RGB color changes.
+* The HomeAssistant Light Component only support on/off, brightness, and RGB.
+  Other color spaces, color temperature, effects, and white channel may work, but are untested.
 * The HomeAssistant Climate Components is not yet supported.
 
 ## Tasmota auto discovery
index 62828ad41a06462db1d184cd3bb2ba30f2d93ed5..5e603b8432178287181ae67512be620971423256 100644 (file)
@@ -136,6 +136,8 @@ public class ComponentChannel {
         private @Nullable String templateIn;
         private @Nullable String templateOut;
 
+        private String format = "%s";
+
         public Builder(AbstractComponent<?> component, String channelID, Value valueState, String label,
                 ChannelStateUpdateListener channelStateUpdateListener) {
             this.component = component;
@@ -206,6 +208,11 @@ public class ComponentChannel {
             return this;
         }
 
+        public Builder withFormat(String format) {
+            this.format = format;
+            return this;
+        }
+
         public ComponentChannel build() {
             return build(true);
         }
@@ -222,11 +229,10 @@ public class ComponentChannel {
                     channelUID.getGroupId() + "_" + channelID);
             channelState = new HomeAssistantChannelState(
                     ChannelConfigBuilder.create().withRetain(retain).withQos(qos).withStateTopic(stateTopic)
-                            .withCommandTopic(commandTopic).makeTrigger(trigger).build(),
+                            .withCommandTopic(commandTopic).makeTrigger(trigger).withFormatter(format).build(),
                     channelUID, valueState, channelStateUpdateListener, commandFilter);
 
-            String localStateTopic = stateTopic;
-            if (localStateTopic == null || localStateTopic.isBlank() || this.trigger) {
+            if (this.trigger) {
                 type = ChannelTypeBuilder.trigger(channelTypeUID, label)
                         .withConfigDescriptionURI(URI.create(MqttBindingConstants.CONFIG_HA_CHANNEL))
                         .isAdvanced(isAdvanced).build();
index d908791ef551e765276d7e9cd7ad436df7150df9..4ce52a8f186374d2b10bff17ffb18f57d56f7f13 100644 (file)
@@ -56,9 +56,18 @@ public class HomeAssistantChannelState extends ChannelState {
 
     @Override
     public CompletableFuture<Boolean> publishValue(Command command) {
-        if (commandFilter != null && !commandFilter.test(command)) {
-            logger.trace("Channel {} updates are disabled by command filter, ignoring command {}", channelUID, command);
-            return CompletableFuture.completedFuture(false);
+        if (commandFilter != null) {
+            try {
+                if (!commandFilter.test(command)) {
+                    logger.trace("Channel {} updates are disabled by command filter, ignoring command {}", channelUID,
+                            command);
+                    return CompletableFuture.completedFuture(false);
+                }
+            } catch (IllegalArgumentException e) {
+                CompletableFuture<Boolean> f = new CompletableFuture<>();
+                f.completeExceptionally(e);
+                return f;
+            }
         }
         return super.publishValue(command);
     }
index cb755ac0fef7c676d457794f3d0719cbaf4c7ee4..98264e322e84cf26cb2f62bba5c24ad360308a9c 100644 (file)
@@ -66,7 +66,7 @@ public class ComponentFactory {
             case "climate":
                 return new Climate(componentConfiguration);
             case "light":
-                return new Light(componentConfiguration);
+                return Light.create(componentConfiguration);
             case "lock":
                 return new Lock(componentConfiguration);
             case "sensor":
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java
new file mode 100644 (file)
index 0000000..e1b2355
--- /dev/null
@@ -0,0 +1,350 @@
+/**
+ * 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.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.mapping.ColorMode;
+import org.openhab.binding.mqtt.generic.values.ColorValue;
+import org.openhab.binding.mqtt.generic.values.TextValue;
+import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
+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;
+
+/**
+ * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
+ *
+ * Specifically, the default schema. This class will present a single channel for color, brightness,
+ * or on/off as appropriate. Additional attributes are still exposed as dedicated channels.
+ *
+ * @author Cody Cutrer - Initial contribution
+ */
+@NonNullByDefault
+public class DefaultSchemaLight extends Light {
+    protected static final String HS_CHANNEL_ID = "hs";
+    protected static final String RGB_CHANNEL_ID = "rgb";
+    protected static final String RGBW_CHANNEL_ID = "rgbw";
+    protected static final String RGBWW_CHANNEL_ID = "rgbww";
+    protected static final String XY_CHANNEL_ID = "xy";
+    protected static final String WHITE_CHANNEL_ID = "white";
+
+    protected @Nullable ComponentChannel hsChannel;
+    protected @Nullable ComponentChannel rgbChannel;
+    protected @Nullable ComponentChannel xyChannel;
+
+    public DefaultSchemaLight(ComponentFactory.ComponentConfiguration builder) {
+        super(builder);
+    }
+
+    @Override
+    protected void buildChannels() {
+        ComponentChannel localOnOffChannel;
+        localOnOffChannel = onOffChannel = buildChannel(ON_OFF_CHANNEL_ID, onOffValue, "On/Off State", this)
+                .stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate)
+                .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
+                        channelConfiguration.getQos())
+                .commandFilter(this::handleRawOnOffCommand).build(false);
+
+        @Nullable
+        ComponentChannel localBrightnessChannel = null;
+        if (channelConfiguration.brightnessStateTopic != null || channelConfiguration.brightnessCommandTopic != null) {
+            localBrightnessChannel = brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, brightnessValue,
+                    "Brightness", this)
+                            .stateTopic(channelConfiguration.brightnessStateTopic,
+                                    channelConfiguration.brightnessValueTemplate)
+                            .commandTopic(channelConfiguration.brightnessCommandTopic, channelConfiguration.isRetain(),
+                                    channelConfiguration.getQos())
+                            .withFormat("%.0f").commandFilter(this::handleBrightnessCommand).build(false);
+        }
+
+        if (channelConfiguration.whiteCommandTopic != null) {
+            buildChannel(WHITE_CHANNEL_ID, brightnessValue, "Go directly to white of a specific brightness", this)
+                    .commandTopic(channelConfiguration.whiteCommandTopic, channelConfiguration.isRetain(),
+                            channelConfiguration.getQos())
+                    .isAdvanced(true).build();
+        }
+
+        if (channelConfiguration.colorModeStateTopic != null) {
+            buildChannel(COLOR_MODE_CHANNEL_ID, new TextValue(), "Current color mode", this)
+                    .stateTopic(channelConfiguration.colorModeStateTopic, channelConfiguration.colorModeValueTemplate)
+                    .build();
+        }
+
+        if (channelConfiguration.colorTempStateTopic != null || channelConfiguration.colorTempCommandTopic != null) {
+            buildChannel(COLOR_TEMP_CHANNEL_ID, colorTempValue, "Color Temperature", this)
+                    .stateTopic(channelConfiguration.colorTempStateTopic, channelConfiguration.colorTempValueTemplate)
+                    .commandTopic(channelConfiguration.colorTempCommandTopic, channelConfiguration.isRetain(),
+                            channelConfiguration.getQos())
+                    .build();
+        }
+
+        if (channelConfiguration.effectStateTopic != null || channelConfiguration.effectCommandTopic != null) {
+            buildChannel(EFFECT_CHANNEL_ID, effectValue, "Lighting effect", this)
+                    .stateTopic(channelConfiguration.effectStateTopic, channelConfiguration.effectValueTemplate)
+                    .commandTopic(channelConfiguration.effectCommandTopic, channelConfiguration.isRetain(),
+                            channelConfiguration.getQos())
+                    .build();
+        }
+
+        if (channelConfiguration.rgbStateTopic != null || channelConfiguration.rgbCommandTopic != null) {
+            hasColorChannel = true;
+            hiddenChannels.add(rgbChannel = buildChannel(RGB_CHANNEL_ID, new ColorValue(ColorMode.RGB, null, null, 100),
+                    "RGB state", this)
+                            .stateTopic(channelConfiguration.rgbStateTopic, channelConfiguration.rgbValueTemplate)
+                            .commandTopic(channelConfiguration.rgbCommandTopic, channelConfiguration.isRetain(),
+                                    channelConfiguration.getQos())
+                            .build(false));
+        }
+
+        if (channelConfiguration.rgbwStateTopic != null || channelConfiguration.rgbwCommandTopic != null) {
+            hasColorChannel = true;
+            hiddenChannels.add(buildChannel(RGBW_CHANNEL_ID, new TextValue(), "RGBW state", this)
+                    .stateTopic(channelConfiguration.rgbwStateTopic, channelConfiguration.rgbwValueTemplate)
+                    .commandTopic(channelConfiguration.rgbwCommandTopic, channelConfiguration.isRetain(),
+                            channelConfiguration.getQos())
+                    .build(false));
+        }
+
+        if (channelConfiguration.rgbwwStateTopic != null || channelConfiguration.rgbwwCommandTopic != null) {
+            hasColorChannel = true;
+            hiddenChannels.add(buildChannel(RGBWW_CHANNEL_ID, new TextValue(), "RGBWW state", this)
+                    .stateTopic(channelConfiguration.rgbwwStateTopic, channelConfiguration.rgbwwValueTemplate)
+                    .commandTopic(channelConfiguration.rgbwwCommandTopic, channelConfiguration.isRetain(),
+                            channelConfiguration.getQos())
+                    .build(false));
+        }
+
+        if (channelConfiguration.xyStateTopic != null || channelConfiguration.xyCommandTopic != null) {
+            hasColorChannel = true;
+            hiddenChannels.add(
+                    xyChannel = buildChannel(XY_CHANNEL_ID, new ColorValue(ColorMode.XYY, null, null, 100), "XY State",
+                            this).stateTopic(channelConfiguration.xyStateTopic, channelConfiguration.xyValueTemplate)
+                                    .commandTopic(channelConfiguration.xyCommandTopic, channelConfiguration.isRetain(),
+                                            channelConfiguration.getQos())
+                                    .build(false));
+        }
+
+        if (channelConfiguration.hsStateTopic != null || channelConfiguration.hsCommandTopic != null) {
+            hasColorChannel = true;
+            hiddenChannels.add(this.hsChannel = buildChannel(HS_CHANNEL_ID, new TextValue(), "Hue and Saturation", this)
+                    .stateTopic(channelConfiguration.hsStateTopic, channelConfiguration.hsValueTemplate)
+                    .commandTopic(channelConfiguration.hsCommandTopic, channelConfiguration.isRetain(),
+                            channelConfiguration.getQos())
+                    .build(false));
+        }
+
+        if (hasColorChannel) {
+            hiddenChannels.add(localOnOffChannel);
+            if (localBrightnessChannel != null) {
+                hiddenChannels.add(localBrightnessChannel);
+            }
+            buildChannel(COLOR_CHANNEL_ID, colorValue, "Color", this)
+                    .commandTopic(DUMMY_TOPIC, channelConfiguration.isRetain(), channelConfiguration.getQos())
+                    .commandFilter(this::handleColorCommand).build();
+        } else if (localBrightnessChannel != null) {
+            hiddenChannels.add(localOnOffChannel);
+            channels.put(BRIGHTNESS_CHANNEL_ID, localBrightnessChannel);
+        } else {
+            channels.put(ON_OFF_CHANNEL_ID, localOnOffChannel);
+        }
+    }
+
+    // all handle*Command methods return false if they've been handled,
+    // or true if default handling should continue
+
+    // The commandFilter for onOffChannel
+    private boolean handleRawOnOffCommand(Command command) {
+        // on_command_type of brightness is not allowed to send an actual on command
+        if (command.equals(OnOffType.ON) && channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_BRIGHTNESS)) {
+            // No prior state (or explicit off); set to 100%
+            if (brightnessValue.getChannelState() instanceof UnDefType
+                    || brightnessValue.getChannelState().equals(PercentType.ZERO)) {
+                brightnessChannel.getState().publishValue(PercentType.HUNDRED);
+            } else {
+                brightnessChannel.getState().publishValue((Command) brightnessValue.getChannelState());
+            }
+            return false;
+        }
+
+        return true;
+    }
+
+    // The helper method the other commandFilters call
+    private boolean handleOnOffCommand(Command command) {
+        if (!handleRawOnOffCommand(command)) {
+            return false;
+        }
+
+        // OnOffType commands to go the regular command topic
+        if (command instanceof OnOffType) {
+            onOffChannel.getState().publishValue(command);
+            return false;
+        }
+
+        boolean needsOn = !onOffValue.getChannelState().equals(OnOffType.ON);
+        if (command.equals(PercentType.ZERO) || command.equals(HSBType.BLACK)) {
+            needsOn = false;
+        }
+        if (needsOn) {
+            if (channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_FIRST)) {
+                onOffChannel.getState().publishValue(OnOffType.ON);
+            } else if (channelConfiguration.onCommandType.equals(ON_COMMAND_TYPE_LAST)) {
+                // TODO: schedule the ON publish for after this is sent
+            }
+        }
+        return true;
+    }
+
+    private boolean handleBrightnessCommand(Command command) {
+        // if it's OnOffType, it'll get handled by this; otherwise it'll return
+        // true and PercentType will be handled as normal
+        return handleOnOffCommand(command);
+    }
+
+    private boolean handleColorCommand(Command command) {
+        if (!handleOnOffCommand(command)) {
+            return false;
+        } else if (command instanceof HSBType) {
+            HSBType color = (HSBType) command;
+            if (channelConfiguration.hsCommandTopic != null) {
+                // If we don't have a brightness channel, something is probably busted
+                // but don't choke
+                if (channelConfiguration.brightnessCommandTopic != null) {
+                    brightnessChannel.getState().publishValue(color.getBrightness());
+                }
+                String hs = String.format("%d,%d", color.getHue().intValue(), color.getSaturation().intValue());
+                hsChannel.getState().publishValue(new StringType(hs));
+            } else if (channelConfiguration.rgbCommandTopic != null) {
+                rgbChannel.getState().publishValue(command);
+                // } else if (channelConfiguration.rgbwCommandTopic != null) {
+                // TODO
+                // } else if (channelConfiguration.rgbwwCommandTopic != null) {
+                // TODO
+            } else if (channelConfiguration.xyCommandTopic != null) {
+                PercentType[] xy = color.toXY();
+                // If we don't have a brightness channel, something is probably busted
+                // but don't choke
+                if (channelConfiguration.brightnessCommandTopic != null) {
+                    brightnessChannel.getState().publishValue(color.getBrightness());
+                }
+                String xyString = String.format("%f,%f", xy[0].doubleValue(), xy[1].doubleValue());
+                xyChannel.getState().publishValue(new StringType(xyString));
+            }
+        } else if (command instanceof PercentType) {
+            if (channelConfiguration.brightnessCommandTopic != null) {
+                brightnessChannel.getState().publishValue(command);
+            } else {
+                // No brightness command topic?! must be RGB only
+                // so re-calculatate
+                State color = colorValue.getChannelState();
+                if (color instanceof UnDefType) {
+                    color = HSBType.WHITE;
+                }
+                HSBType existingColor = (HSBType) color;
+                HSBType newCommand = new HSBType(existingColor.getHue(), existingColor.getSaturation(),
+                        (PercentType) command);
+                // re-process
+                handleColorCommand(newCommand);
+            }
+        }
+        return false;
+    }
+
+    @Override
+    public void updateChannelState(ChannelUID channel, State state) {
+        ChannelStateUpdateListener listener = this.channelStateUpdateListener;
+        switch (channel.getIdWithoutGroup()) {
+            case ON_OFF_CHANNEL_ID:
+                if (hasColorChannel) {
+                    HSBType newOnState = colorValue.getChannelState() instanceof HSBType
+                            ? (HSBType) colorValue.getChannelState()
+                            : HSBType.WHITE;
+                    if (state.equals(OnOffType.ON)) {
+                        colorValue.update(newOnState);
+                    }
+
+                    listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
+                            state.equals(OnOffType.ON) ? newOnState : HSBType.BLACK);
+                } else if (brightnessChannel != null) {
+                    listener.updateChannelState(new ChannelUID(channel.getThingUID(), BRIGHTNESS_CHANNEL_ID),
+                            state.equals(OnOffType.ON) ? brightnessValue.getChannelState() : PercentType.ZERO);
+                } else {
+                    listener.updateChannelState(channel, state);
+                }
+                return;
+            case BRIGHTNESS_CHANNEL_ID:
+                onOffValue.update(Objects.requireNonNull(state.as(OnOffType.class)));
+                if (hasColorChannel) {
+                    if (colorValue.getChannelState() instanceof HSBType) {
+                        HSBType hsb = (HSBType) (colorValue.getChannelState());
+                        colorValue.update(new HSBType(hsb.getHue(), hsb.getSaturation(),
+                                (PercentType) brightnessValue.getChannelState()));
+                    } else {
+                        colorValue.update(new HSBType(DecimalType.ZERO, PercentType.ZERO,
+                                (PercentType) brightnessValue.getChannelState()));
+                    }
+                    listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
+                            colorValue.getChannelState());
+                } else {
+                    listener.updateChannelState(channel, state);
+                }
+                return;
+            case COLOR_TEMP_CHANNEL_ID:
+            case EFFECT_CHANNEL_ID:
+                // Real channels; pass through
+                listener.updateChannelState(channel, state);
+                return;
+            case HS_CHANNEL_ID:
+            case XY_CHANNEL_ID:
+                if (brightnessValue.getChannelState() instanceof UnDefType) {
+                    brightnessValue.update(PercentType.HUNDRED);
+                }
+                String[] split = state.toString().split(",");
+                if (split.length != 2) {
+                    throw new IllegalArgumentException(state.toString() + " is not a valid string syntax");
+                }
+                float x = Float.parseFloat(split[0]);
+                float y = Float.parseFloat(split[1]);
+                PercentType brightness = (PercentType) brightnessValue.getChannelState();
+                if (channel.getIdWithoutGroup().equals(HS_CHANNEL_ID)) {
+                    colorValue.update(new HSBType(new DecimalType(x), new PercentType(new BigDecimal(y)), brightness));
+                } else {
+                    HSBType xyColor = HSBType.fromXY(x, y);
+                    colorValue.update(new HSBType(xyColor.getHue(), xyColor.getSaturation(), brightness));
+                }
+                listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
+                        colorValue.getChannelState());
+                return;
+            case RGB_CHANNEL_ID:
+                colorValue.update((HSBType) state);
+                listener.updateChannelState(new ChannelUID(getGroupUID(), COLOR_CHANNEL_ID),
+                        colorValue.getChannelState());
+                break;
+            case RGBW_CHANNEL_ID:
+            case RGBWW_CHANNEL_ID:
+                // TODO: update color value
+                break;
+        }
+    }
+}
index e9f2681863fef765c23d829f08f9ec75cac3bc76..32c0308debc01fa930b4e980ee622a89dd367985 100644 (file)
  */
 package org.openhab.binding.mqtt.homeassistant.internal.component;
 
+import java.math.BigDecimal;
+import java.util.ArrayList;
 import java.util.List;
+import java.util.Objects;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.stream.Stream;
@@ -22,28 +25,56 @@ import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener;
 import org.openhab.binding.mqtt.generic.mapping.ColorMode;
 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.binding.mqtt.generic.values.TextValue;
 import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel;
 import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration;
+import org.openhab.binding.mqtt.homeassistant.internal.exception.UnsupportedComponentException;
 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
+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 com.google.gson.annotations.SerializedName;
 
 /**
- * A MQTT light, following the https://www.home-assistant.io/components/light.mqtt/ specification.
+ * A MQTT light, following the
+ * https://www.home-assistant.io/components/light.mqtt/ specification.
  *
- * This class condenses the three state/command topics (for ON/OFF, Brightness, Color) to one
- * color channel.
+ * Individual concrete classes implement the differing semantics of the
+ * three different schemas.
+ *
+ * As of now, only on/off, brightness, and RGB are fully implemented and tested.
+ * HS and XY are implemented, but not tested. Color temp and effect are only
+ * implemented (but not tested) for the default schema.
  *
  * @author David Graeff - Initial contribution
+ * @author Cody Cutrer - Re-write for (nearly) full support
  */
 @NonNullByDefault
-public class Light extends AbstractComponent<Light.ChannelConfiguration> implements ChannelStateUpdateListener {
-    public static final String SWITCH_CHANNEL_ID = "light"; // Randomly chosen channel "ID"
-    public static final String BRIGHTNESS_CHANNEL_ID = "brightness"; // Randomly chosen channel "ID"
-    public static final String COLOR_CHANNEL_ID = "color"; // Randomly chosen channel "ID"
+public abstract class Light extends AbstractComponent<Light.ChannelConfiguration>
+        implements ChannelStateUpdateListener {
+    protected static final String DEFAULT_SCHEMA = "default";
+    protected static final String JSON_SCHEMA = "json";
+    protected static final String TEMPLATE_SCHEMA = "template";
+
+    protected static final String STATE_CHANNEL_ID = "state";
+    protected static final String ON_OFF_CHANNEL_ID = "on_off";
+    protected static final String BRIGHTNESS_CHANNEL_ID = "brightness";
+    protected static final String COLOR_MODE_CHANNEL_ID = "color_mode";
+    protected static final String COLOR_TEMP_CHANNEL_ID = "color_temp";
+    protected static final String EFFECT_CHANNEL_ID = "effect";
+    // This channel is a synthetic channel that may send to other channels
+    // underneath
+    protected static final String COLOR_CHANNEL_ID = "color";
+
+    protected static final String DUMMY_TOPIC = "dummy";
+
+    protected static final String ON_COMMAND_TYPE_FIRST = "first";
+    protected static final String ON_COMMAND_TYPE_BRIGHTNESS = "brightness";
+    protected static final String ON_COMMAND_TYPE_LAST = "last";
 
     /**
      * Configuration class for MQTT component
@@ -53,155 +84,238 @@ public class Light extends AbstractComponent<Light.ChannelConfiguration> impleme
             super("MQTT Light");
         }
 
-        @SerializedName("brightness_scale")
-        protected int brightnessScale = 255;
-        protected boolean optimistic = false;
-        @SerializedName("effect_list")
-        protected @Nullable List<String> effectList;
+        /* Attributes that control the basic structure of the light */
 
-        // 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 send the payload_on and then any
-        // style topics. Using brightness will only send brightness commands instead of the payload_on to turn the light
+        protected String schema = DEFAULT_SCHEMA;
+        protected @Nullable Boolean optimistic; // All schemas
+        protected boolean brightness = false; // JSON schema only
+        @SerializedName("color_mode")
+        protected boolean colorMode = false; // JSON schema only
+        @SerializedName("supported_color_modes")
+        protected @Nullable List<String> 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
+        // send the payload_on and then any
+        // style topics. Using brightness will only send brightness commands instead of
+        // the payload_on to turn the light
         // on.
         @SerializedName("on_command_type")
-        protected String onCommandType = "last";
+        protected String onCommandType = ON_COMMAND_TYPE_LAST; // Default schema only
+
+        /* Basic control attributes */
 
         @SerializedName("state_topic")
-        protected @Nullable String stateTopic;
-        @SerializedName("command_topic")
-        protected @Nullable String commandTopic;
+        protected @Nullable String stateTopic; // All Schemas
         @SerializedName("state_value_template")
-        protected @Nullable String stateValueTemplate;
+        protected @Nullable String stateValueTemplate; // Default schema only
+        @SerializedName("state_template")
+        protected @Nullable String stateTemplate; // Template schema only
+        @SerializedName("payload_on")
+        protected String payloadOn = "ON"; // Default schema only
+        @SerializedName("payload_off")
+        protected String payloadOff = "OFF"; // Default schema only
+        @SerializedName("command_topic")
+        protected @Nullable String commandTopic; // All schemas
+        @SerializedName("command_on_template")
+        protected @Nullable String commandOnTemplate; // Template schema only; required
+        @SerializedName("command_off_template")
+        protected @Nullable String commandOffTemplate; // Template schema only; required
+
+        /* Brightness attributes */
 
+        @SerializedName("brightness_scale")
+        protected int brightnessScale = 255; // Default, JSON schemas only
         @SerializedName("brightness_state_topic")
-        protected @Nullable String brightnessStateTopic;
-        @SerializedName("brightness_command_topic")
-        protected @Nullable String brightnessCommandTopic;
+        protected @Nullable String brightnessStateTopic; // Default schema only
         @SerializedName("brightness_value_template")
-        protected @Nullable String brightnessValueTemplate;
+        protected @Nullable String brightnessValueTemplate; // Default schema only
+        @SerializedName("brightness_template")
+        protected @Nullable String brightnessTemplate; // Template schema only
+        @SerializedName("brightness_command_topic")
+        protected @Nullable String brightnessCommandTopic; // Default schema only
+        @SerializedName("brightness_command_template")
+        protected @Nullable String brightnessCommandTemplate; // Default schema only
 
+        /* White value attributes */
+
+        @SerializedName("white_scale")
+        protected int whiteScale = 255; // Default, JSON schemas only
+        @SerializedName("white_command_topic")
+        protected @Nullable String whiteCommandTopic; // Default schema only
+
+        /* Color mode attributes */
+
+        @SerializedName("color_mode_state_topic")
+        protected @Nullable String colorModeStateTopic; // Default schema only
+        @SerializedName("color_mode_value_template")
+        protected @Nullable String colorModeValueTemplate; // Default schema only
+
+        /* Color temp attributes */
+
+        @SerializedName("min_mireds")
+        protected @Nullable Integer minMireds; // All schemas
+        @SerializedName("max_mireds")
+        protected @Nullable Integer maxMireds; // All schemas
         @SerializedName("color_temp_state_topic")
-        protected @Nullable String colorTempStateTopic;
-        @SerializedName("color_temp_command_topic")
-        protected @Nullable String colorTempCommandTopic;
+        protected @Nullable String colorTempStateTopic; // Default schema only
         @SerializedName("color_temp_value_template")
-        protected @Nullable String colorTempValueTemplate;
+        protected @Nullable String colorTempValueTemplate; // Default schema only
+        @SerializedName("color_temp_template")
+        protected @Nullable String colorTempTemplate; // Template schema only
+        @SerializedName("color_temp_command_topic")
+        protected @Nullable String colorTempCommandTopic; // Default schema only
+        @SerializedName("color_temp_command_template")
+        protected @Nullable String colorTempCommandTemplate; // Default schema only
 
-        @SerializedName("effect_command_topic")
-        protected @Nullable String effectCommandTopic;
+        /* Effect attributes */
+        @SerializedName("effect_list")
+        protected @Nullable List<String> effectList; // All schemas
         @SerializedName("effect_state_topic")
-        protected @Nullable String effectStateTopic;
+        protected @Nullable String effectStateTopic; // Default schema only
         @SerializedName("effect_value_template")
-        protected @Nullable String effectValueTemplate;
+        protected @Nullable String effectValueTemplate; // Default schema only
+        @SerializedName("effect_template")
+        protected @Nullable String effectTemplate; // Template schema only
+        @SerializedName("effect_command_topic")
+        protected @Nullable String effectCommandTopic; // Default schema only
+        @SerializedName("effect_command_template")
+        protected @Nullable String effectCommandTemplate; // Default schema only
 
-        @SerializedName("rgb_command_topic")
-        protected @Nullable String rgbCommandTopic;
+        /* HS attributes */
+        @SerializedName("hs_state_topic")
+        protected @Nullable String hsStateTopic; // Default schema only
+        @SerializedName("hs_value_template")
+        protected @Nullable String hsValueTemplate; // Default schema only
+        @SerializedName("hs_command_topic")
+        protected @Nullable String hsCommandTopic; // Default schema only
+
+        /* RGB attributes */
         @SerializedName("rgb_state_topic")
-        protected @Nullable String rgbStateTopic;
+        protected @Nullable String rgbStateTopic; // Default schema only
         @SerializedName("rgb_value_template")
-        protected @Nullable String rgbValueTemplate;
+        protected @Nullable String rgbValueTemplate; // Default schema only
+        @SerializedName("red_template")
+        protected @Nullable String redTemplate; // Template schema only
+        @SerializedName("green_template")
+        protected @Nullable String greenTemplate; // Template schema only
+        @SerializedName("blue_template")
+        protected @Nullable String blueTemplate; // Template schema only
+        @SerializedName("rgb_command_topic")
+        protected @Nullable String rgbCommandTopic; // Default schema only
         @SerializedName("rgb_command_template")
-        protected @Nullable String rgbCommandTemplate;
+        protected @Nullable String rgbCommandTemplate; // Default schema only
+
+        /* RGBW attributes */
+        @SerializedName("rgbw_state_topic")
+        protected @Nullable String rgbwStateTopic; // Default schema only
+        @SerializedName("rgbw_value_template")
+        protected @Nullable String rgbwValueTemplate; // Default schema only
+        @SerializedName("rgbw_command_topic")
+        protected @Nullable String rgbwCommandTopic; // Default schema only
+        @SerializedName("rgbw_command_template")
+        protected @Nullable String rgbwCommandTemplate; // Default schema only
 
-        @SerializedName("white_value_command_topic")
-        protected @Nullable String whiteValueCommandTopic;
-        @SerializedName("white_value_state_topic")
-        protected @Nullable String whiteValueStateTopic;
-        @SerializedName("white_value_template")
-        protected @Nullable String whiteValueTemplate;
+        /* RGBWW attributes */
+        @SerializedName("rgbww_state_topic")
+        protected @Nullable String rgbwwStateTopic; // Default schema only
+        @SerializedName("rgbww_value_template")
+        protected @Nullable String rgbwwValueTemplate; // Default schema only
+        @SerializedName("rgbww_command_topic")
+        protected @Nullable String rgbwwCommandTopic; // Default schema only
+        @SerializedName("rgbww_command_template")
+        protected @Nullable String rgbwwCommandTemplate; // Default schema only
 
+        /* XY attributes */
         @SerializedName("xy_command_topic")
-        protected @Nullable String xyCommandTopic;
+        protected @Nullable String xyCommandTopic; // Default schema only
         @SerializedName("xy_state_topic")
-        protected @Nullable String xyStateTopic;
+        protected @Nullable String xyStateTopic; // Default schema only
         @SerializedName("xy_value_template")
-        protected @Nullable String xyValueTemplate;
-
-        @SerializedName("payload_on")
-        protected String payloadOn = "ON";
-        @SerializedName("payload_off")
-        protected String payloadOff = "OFF";
+        protected @Nullable String xyValueTemplate; // Default schema only
     }
 
-    protected ComponentChannel colorChannel;
-    protected ComponentChannel switchChannel;
-    protected ComponentChannel brightnessChannel;
-    private final @Nullable ChannelStateUpdateListener channelStateUpdateListener;
+    protected final boolean optimistic;
+    protected boolean hasColorChannel = false;
+
+    protected @Nullable ComponentChannel onOffChannel;
+    protected @Nullable ComponentChannel brightnessChannel;
+
+    // State has to be stored here, in order to mux multiple
+    // MQTT sources into single OpenHAB channels
+    protected OnOffValue onOffValue;
+    protected PercentageValue brightnessValue;
+    protected final NumberValue colorTempValue;
+    protected final TextValue effectValue = new TextValue();
+    protected final ColorValue colorValue = new ColorValue(ColorMode.HSB, null, null, 100);
+
+    protected final List<ComponentChannel> hiddenChannels = new ArrayList<>();
+    protected final ChannelStateUpdateListener channelStateUpdateListener;
+
+    public static Light create(ComponentFactory.ComponentConfiguration builder) throws UnsupportedComponentException {
+        String schema = builder.getConfig(ChannelConfiguration.class).schema;
+        switch (schema) {
+            case DEFAULT_SCHEMA:
+                return new DefaultSchemaLight(builder);
+            default:
+                throw new UnsupportedComponentException(
+                        "Component '" + builder.getHaID() + "' of schema '" + schema + "' is not supported!");
+        }
+    }
 
-    public Light(ComponentFactory.ComponentConfiguration builder) {
+    protected Light(ComponentFactory.ComponentConfiguration builder) {
         super(builder, ChannelConfiguration.class);
         this.channelStateUpdateListener = builder.getUpdateListener();
-        ColorValue value = new ColorValue(ColorMode.RGB, channelConfiguration.payloadOn,
-                channelConfiguration.payloadOff, 100);
-
-        // Create three MQTT subscriptions and use this class object as update listener
-        switchChannel = buildChannel(SWITCH_CHANNEL_ID, value, channelConfiguration.getName(), this)
-                .stateTopic(channelConfiguration.stateTopic, channelConfiguration.stateValueTemplate,
-                        channelConfiguration.getValueTemplate())
-                .commandTopic(channelConfiguration.commandTopic, channelConfiguration.isRetain(),
-                        channelConfiguration.getQos())
-                .build(false);
-
-        colorChannel = buildChannel(COLOR_CHANNEL_ID, value, channelConfiguration.getName(), this)
-                .stateTopic(channelConfiguration.rgbStateTopic, channelConfiguration.rgbValueTemplate)
-                .commandTopic(channelConfiguration.rgbCommandTopic, channelConfiguration.isRetain(),
-                        channelConfiguration.getQos())
-                .build(false);
-
-        brightnessChannel = buildChannel(BRIGHTNESS_CHANNEL_ID, value, channelConfiguration.getName(), this)
-                .stateTopic(channelConfiguration.brightnessStateTopic, channelConfiguration.brightnessValueTemplate)
-                .commandTopic(channelConfiguration.brightnessCommandTopic, channelConfiguration.isRetain(),
-                        channelConfiguration.getQos())
-                .build(false);
-
-        channels.put(COLOR_CHANNEL_ID, colorChannel);
+
+        @Nullable
+        Boolean optimistic = channelConfiguration.optimistic;
+        if (optimistic != null) {
+            this.optimistic = optimistic;
+        } else {
+            this.optimistic = (channelConfiguration.stateTopic == null);
+        }
+
+        onOffValue = new OnOffValue(channelConfiguration.payloadOn, channelConfiguration.payloadOff);
+        brightnessValue = new PercentageValue(null, new BigDecimal(channelConfiguration.brightnessScale), null, null,
+                null);
+        @Nullable
+        BigDecimal min = null, max = null;
+        if (channelConfiguration.minMireds != null) {
+            min = new BigDecimal(channelConfiguration.minMireds);
+        }
+        if (channelConfiguration.maxMireds != null) {
+            max = new BigDecimal(channelConfiguration.maxMireds);
+        }
+        colorTempValue = new NumberValue(min, max, BigDecimal.ONE, Units.MIRED);
+
+        buildChannels();
     }
 
+    protected abstract void buildChannels();
+
     @Override
     public CompletableFuture<@Nullable Void> start(MqttBrokerConnection connection, ScheduledExecutorService scheduler,
             int timeout) {
-        return Stream.of(switchChannel, brightnessChannel, colorChannel) //
+        return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
                 .map(v -> v.start(connection, scheduler, timeout)) //
                 .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
     }
 
     @Override
     public CompletableFuture<@Nullable Void> stop() {
-        return Stream.of(switchChannel, brightnessChannel, colorChannel) //
-                .map(v -> v.stop()) //
+        return Stream.concat(channels.values().stream(), hiddenChannels.stream()) //
+                .filter(Objects::nonNull) //
+                .map(ComponentChannel::stop) //
                 .reduce(CompletableFuture.completedFuture(null), (f, v) -> f.thenCompose(b -> v));
     }
 
-    /**
-     * Proxy method to condense all three MQTT subscriptions to one channel
-     */
-    @Override
-    public void updateChannelState(ChannelUID channelUID, State value) {
-        ChannelStateUpdateListener listener = channelStateUpdateListener;
-        if (listener != null) {
-            listener.updateChannelState(colorChannel.getChannelUID(), value);
-        }
-    }
-
-    /**
-     * Proxy method to condense all three MQTT subscriptions to one channel
-     */
     @Override
     public void postChannelCommand(ChannelUID channelUID, Command value) {
-        ChannelStateUpdateListener listener = channelStateUpdateListener;
-        if (listener != null) {
-            listener.postChannelCommand(colorChannel.getChannelUID(), value);
-        }
+        throw new UnsupportedOperationException();
     }
 
-    /**
-     * Proxy method to condense all three MQTT subscriptions to one channel
-     */
     @Override
     public void triggerChannel(ChannelUID channelUID, String eventPayload) {
-        ChannelStateUpdateListener listener = channelStateUpdateListener;
-        if (listener != null) {
-            listener.triggerChannel(colorChannel.getChannelUID(), eventPayload);
-        }
+        throw new UnsupportedOperationException();
     }
 }
index cfc1df26d8b4c422002f3d596eb164f98d687f55..2336b80afe3f435b13d0dc27fe1f95417aa9d3c2 100644 (file)
@@ -51,6 +51,7 @@ import org.openhab.binding.mqtt.homeassistant.internal.handler.HomeAssistantThin
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingStatusInfo;
 import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.types.Command;
 import org.openhab.core.types.State;
 
 /**
@@ -244,6 +245,19 @@ public abstract class AbstractComponentTests extends AbstractHomeAssistantTests
         return false;
     }
 
+    /**
+     * Send command to a thing's channel
+     * 
+     * @param component component
+     * @param channelId channel
+     * @param command command to send
+     */
+    protected void sendCommand(AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> component,
+            String channelId, Command command) {
+        var channel = Objects.requireNonNull(component.getChannel(channelId));
+        thingHandler.handleCommand(channel.getChannelUID(), command);
+    }
+
     protected static class LatchThingHandler extends HomeAssistantThingHandler {
         private @Nullable CountDownLatch latch;
         private @Nullable AbstractComponent<@NonNull ? extends AbstractChannelConfiguration> discoveredComponent;
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLightTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLightTests.java
new file mode 100644 (file)
index 0000000..9fda31b
--- /dev/null
@@ -0,0 +1,282 @@
+/**
+ * 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.CoreMatchers.notNullValue;
+import static org.hamcrest.CoreMatchers.nullValue;
+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.eclipse.jdt.annotation.Nullable;
+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.binding.mqtt.homeassistant.internal.ComponentChannel;
+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;
+
+/**
+ * Tests for {@link Light} confirming to the default schema
+ *
+ * @author Anton Kharuzhy - Initial contribution
+ */
+@NonNullByDefault
+public class DefaultSchemaLightTests 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\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"state_value_template\": \"{{ value_json.power }}\", " +
+                        "  \"payload_on\": \"ON_\", " +
+                        "  \"payload_off\": \"OFF_\", " +
+                        "  \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
+                        "  \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
+                        "  \"rgb_value_template\": \"{{ value_json.rgb }}\", " +
+                        "  \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
+                        "  \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
+                        "  \"brightness_value_template\": \"{{ value_json.br }}\" " +
+                        "}");
+        // @formatter:on
+
+        assertThat(component.channels.size(), is(1));
+        assertThat(component.getName(), is("light"));
+
+        assertChannel(component, Light.COLOR_CHANNEL_ID, "", "dummy", "Color", ColorValue.class);
+
+        @Nullable
+        ComponentChannel onOffChannel = component.onOffChannel;
+        assertThat(onOffChannel, is(notNullValue()));
+        if (onOffChannel != null) {
+            assertChannel(onOffChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "On/Off State",
+                    OnOffValue.class);
+        }
+        @Nullable
+        ComponentChannel brightnessChannel = component.brightnessChannel;
+        assertThat(brightnessChannel, is(notNullValue()));
+        if (brightnessChannel != null) {
+            assertChannel(brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
+                    "Brightness", PercentageValue.class);
+        }
+
+        publishMessage("zigbee2mqtt/light/state", "{\"power\": \"ON_\"}");
+        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
+        publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"10,20,30\"}");
+        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(10, 20, 30));
+        publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
+        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
+
+        sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE);
+        assertPublished("zigbee2mqtt/light/set/rgb", "0,0,255");
+
+        // Brightness commands should route to the correct topic
+        sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
+        assertPublished("zigbee2mqtt/light/set/brightness", "128");
+
+        // 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 testRgbWithoutBrightness() throws InterruptedException {
+        // @formatter:off
+        var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+                "{ " +
+                        "  \"name\": \"light\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"state_value_template\": \"{{ value_json.power }}\", " +
+                        "  \"payload_on\": \"ON_\", " +
+                        "  \"payload_off\": \"OFF_\", " +
+                        "  \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
+                        "  \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
+                        "  \"rgb_value_template\": \"{{ value_json.rgb }}\"" +
+                        "}");
+        // @formatter:on
+
+        publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
+        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.WHITE);
+
+        // Brightness commands should route to the correct topic, converted to RGB
+        sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
+        assertPublished("zigbee2mqtt/light/set/rgb", "127,127,127");
+
+        // 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 testHsb() throws InterruptedException {
+        // @formatter:off
+        var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+                "{ " +
+                        "  \"name\": \"light\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"state_value_template\": \"{{ value_json.power }}\", " +
+                        "  \"payload_on\": \"ON_\", " +
+                        "  \"payload_off\": \"OFF_\", " +
+                        "  \"hs_state_topic\": \"zigbee2mqtt/light/hs\", " +
+                        "  \"hs_command_topic\": \"zigbee2mqtt/light/set/hs\", " +
+                        "  \"hs_value_template\": \"{{ value_json.hs }}\", " +
+                        "  \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
+                        "  \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
+                        "  \"brightness_value_template\": \"{{ value_json.br }}\" " +
+                        "}");
+        // @formatter:on
+
+        assertThat(component.channels.size(), is(1));
+        assertThat(component.getName(), is("light"));
+
+        assertChannel(component, Light.COLOR_CHANNEL_ID, "", "dummy", "Color", ColorValue.class);
+
+        @Nullable
+        ComponentChannel onOffChannel = component.onOffChannel;
+        assertThat(onOffChannel, is(notNullValue()));
+        if (onOffChannel != null) {
+            assertChannel(onOffChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "On/Off State",
+                    OnOffValue.class);
+        }
+        @Nullable
+        ComponentChannel brightnessChannel = component.brightnessChannel;
+        assertThat(brightnessChannel, is(notNullValue()));
+        if (brightnessChannel != null) {
+            assertChannel(brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
+                    "Brightness", PercentageValue.class);
+        }
+
+        publishMessage("zigbee2mqtt/light/hs", "{\"hs\": \"180,50\"}");
+        publishMessage("zigbee2mqtt/light/brightness", "{\"br\": \"128\"}");
+        assertState(component, Light.COLOR_CHANNEL_ID, new HSBType(new DecimalType(180), new PercentType(50),
+                new PercentType(new BigDecimal(128 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128))));
+
+        sendCommand(component, Light.COLOR_CHANNEL_ID, HSBType.BLUE);
+        assertPublished("zigbee2mqtt/light/set/brightness", "255");
+        assertPublished("zigbee2mqtt/light/set/hs", "240,100");
+
+        // Brightness commands should route to the correct topic
+        sendCommand(component, Light.COLOR_CHANNEL_ID, new PercentType(50));
+        assertPublished("zigbee2mqtt/light/set/brightness", "128");
+
+        // 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 {
+        // @formatter:off
+        var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+                "{ " +
+                        "  \"name\": \"light\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"state_value_template\": \"{{ value_json.power }}\", " +
+                        "  \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
+                        "  \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
+                        "  \"payload_on\": \"ON_\", " +
+                        "  \"payload_off\": \"OFF_\" " +
+                        "}");
+        // @formatter:on
+
+        assertThat(component.channels.size(), is(1));
+        assertThat(component.getName(), is("light"));
+
+        assertChannel(component, Light.BRIGHTNESS_CHANNEL_ID, "zigbee2mqtt/light/brightness",
+                "zigbee2mqtt/light/set/brightness", "Brightness", PercentageValue.class);
+        @Nullable
+        ComponentChannel onOffChannel = component.onOffChannel;
+        assertThat(onOffChannel, is(notNullValue()));
+        if (onOffChannel != null) {
+            assertChannel(onOffChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "On/Off State",
+                    OnOffValue.class);
+        }
+
+        publishMessage("zigbee2mqtt/light/brightness", "128");
+        assertState(component, Light.BRIGHTNESS_CHANNEL_ID,
+                new PercentType(new BigDecimal(128 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128)));
+        publishMessage("zigbee2mqtt/light/brightness", "64");
+        assertState(component, Light.BRIGHTNESS_CHANNEL_ID,
+                new PercentType(new BigDecimal(64 * 100).divide(new BigDecimal(255), MathContext.DECIMAL128)));
+
+        sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.OFF);
+        assertPublished("zigbee2mqtt/light/set/state", "OFF_");
+
+        sendCommand(component, Light.BRIGHTNESS_CHANNEL_ID, OnOffType.ON);
+        assertPublished("zigbee2mqtt/light/set/state", "ON_");
+    }
+
+    @Test
+    public void testOnOffOnly() throws InterruptedException {
+        // @formatter:off
+        var component = (Light) discoverComponent(configTopicToMqtt(CONFIG_TOPIC),
+                "{ " +
+                        "  \"name\": \"light\", " +
+                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
+                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
+                        "  \"state_value_template\": \"{{ value_json.power }}\", " +
+                        "  \"payload_on\": \"ON_\", " +
+                        "  \"payload_off\": \"OFF_\" " +
+                        "}");
+        // @formatter:on
+
+        assertThat(component.channels.size(), is(1));
+        assertThat(component.getName(), is("light"));
+
+        assertChannel(component, Light.ON_OFF_CHANNEL_ID, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state",
+                "On/Off State", OnOffValue.class);
+        assertThat(component.brightnessChannel, is(nullValue()));
+
+        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_");
+    }
+
+    @Override
+    protected Set<String> getConfigTopics() {
+        return Set.of(CONFIG_TOPIC);
+    }
+}
diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java
deleted file mode 100644 (file)
index 219b6c3..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * 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.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.core.library.types.HSBType;
-import org.openhab.core.library.types.OnOffType;
-
-/**
- * Tests for {@link Light}
- * The current {@link Light} is non-compliant with the Specification and must be rewritten from scratch.
- *
- * @author Anton Kharuzhy - Initial contribution
- */
-@NonNullByDefault
-public class LightTests extends AbstractComponentTests {
-    public static final String CONFIG_TOPIC = "light/0x0000000000000000_light_zigbee2mqtt";
-
-    @Test
-    public void test() 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\", " +
-                        "  \"state_topic\": \"zigbee2mqtt/light/state\", " +
-                        "  \"command_topic\": \"zigbee2mqtt/light/set/state\", " +
-                        "  \"state_value_template\": \"{{ value_json.power }}\", " +
-                        "  \"payload_on\": \"ON_\", " +
-                        "  \"payload_off\": \"OFF_\", " +
-                        "  \"rgb_state_topic\": \"zigbee2mqtt/light/rgb\", " +
-                        "  \"rgb_command_topic\": \"zigbee2mqtt/light/set/rgb\", " +
-                        "  \"rgb_value_template\": \"{{ value_json.rgb }}\", " +
-                        "  \"brightness_state_topic\": \"zigbee2mqtt/light/brightness\", " +
-                        "  \"brightness_command_topic\": \"zigbee2mqtt/light/set/brightness\", " +
-                        "  \"brightness_value_template\": \"{{ value_json.br }}\" " +
-                        "}");
-        // @formatter:on
-
-        assertThat(component.channels.size(), is(1));
-        assertThat(component.getName(), is("light"));
-
-        assertChannel(component, Light.COLOR_CHANNEL_ID, "zigbee2mqtt/light/rgb", "zigbee2mqtt/light/set/rgb", "light",
-                ColorValue.class);
-
-        assertChannel(component.switchChannel, "zigbee2mqtt/light/state", "zigbee2mqtt/light/set/state", "light",
-                ColorValue.class);
-        assertChannel(component.brightnessChannel, "zigbee2mqtt/light/brightness", "zigbee2mqtt/light/set/brightness",
-                "light", ColorValue.class);
-
-        publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"255,255,255\"}");
-        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(255, 255, 255));
-        publishMessage("zigbee2mqtt/light/rgb", "{\"rgb\": \"10,20,30\"}");
-        assertState(component, Light.COLOR_CHANNEL_ID, HSBType.fromRGB(10, 20, 30));
-
-        component.switchChannel.getState().publishValue(OnOffType.OFF);
-        assertPublished("zigbee2mqtt/light/set/state", "0,0,0");
-    }
-
-    @Override
-    protected Set<String> getConfigTopics() {
-        return Set.of(CONFIG_TOPIC);
-    }
-}