diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/README.md b/bundles/org.openhab.binding.mqtt.homeassistant/README.md index 409a78875..09dd009cf 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/README.md +++ b/bundles/org.openhab.binding.mqtt.homeassistant/README.md @@ -22,7 +22,8 @@ These can be installed under `Settings` → `Addon` → `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 diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java index 62828ad41..5e603b843 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannel.java @@ -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(); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java index d908791ef..4ce52a8f1 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/HomeAssistantChannelState.java @@ -56,9 +56,18 @@ public class HomeAssistantChannelState extends ChannelState { @Override public CompletableFuture 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 f = new CompletableFuture<>(); + f.completeExceptionally(e); + return f; + } } return super.publishValue(command); } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index cb755ac0f..98264e322 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -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 index 000000000..e1b2355d0 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLight.java @@ -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; + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java index e9f268186..32c0308de 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/Light.java @@ -12,7 +12,10 @@ */ 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 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 + 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 impleme super("MQTT Light"); } - @SerializedName("brightness_scale") - protected int brightnessScale = 255; - protected boolean optimistic = false; - @SerializedName("effect_list") - protected @Nullable List 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 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; - - @SerializedName("brightness_state_topic") - protected @Nullable String brightnessStateTopic; - @SerializedName("brightness_command_topic") - protected @Nullable String brightnessCommandTopic; - @SerializedName("brightness_value_template") - protected @Nullable String brightnessValueTemplate; - - @SerializedName("color_temp_state_topic") - protected @Nullable String colorTempStateTopic; - @SerializedName("color_temp_command_topic") - protected @Nullable String colorTempCommandTopic; - @SerializedName("color_temp_value_template") - protected @Nullable String colorTempValueTemplate; - - @SerializedName("effect_command_topic") - protected @Nullable String effectCommandTopic; - @SerializedName("effect_state_topic") - protected @Nullable String effectStateTopic; - @SerializedName("effect_value_template") - protected @Nullable String effectValueTemplate; - - @SerializedName("rgb_command_topic") - protected @Nullable String rgbCommandTopic; - @SerializedName("rgb_state_topic") - protected @Nullable String rgbStateTopic; - @SerializedName("rgb_value_template") - protected @Nullable String rgbValueTemplate; - @SerializedName("rgb_command_template") - protected @Nullable String rgbCommandTemplate; - - @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; - - @SerializedName("xy_command_topic") - protected @Nullable String xyCommandTopic; - @SerializedName("xy_state_topic") - protected @Nullable String xyStateTopic; - @SerializedName("xy_value_template") - protected @Nullable String xyValueTemplate; - + protected @Nullable String stateValueTemplate; // Default schema only + @SerializedName("state_template") + protected @Nullable String stateTemplate; // Template schema only @SerializedName("payload_on") - protected String payloadOn = "ON"; + protected String payloadOn = "ON"; // Default schema only @SerializedName("payload_off") - protected String payloadOff = "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; // Default schema only + @SerializedName("brightness_value_template") + 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; // Default schema only + @SerializedName("color_temp_value_template") + 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 + + /* Effect attributes */ + @SerializedName("effect_list") + protected @Nullable List effectList; // All schemas + @SerializedName("effect_state_topic") + protected @Nullable String effectStateTopic; // Default schema only + @SerializedName("effect_value_template") + 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 + + /* 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; // Default schema only + @SerializedName("rgb_value_template") + 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; // 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 + + /* 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; // Default schema only + @SerializedName("xy_state_topic") + protected @Nullable String xyStateTopic; // Default schema only + @SerializedName("xy_value_template") + 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; - public Light(ComponentFactory.ComponentConfiguration builder) { + 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 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!"); + } + } + + 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); + @Nullable + Boolean optimistic = channelConfiguration.optimistic; + if (optimistic != null) { + this.optimistic = optimistic; + } else { + this.optimistic = (channelConfiguration.stateTopic == null); + } - colorChannel = buildChannel(COLOR_CHANNEL_ID, value, channelConfiguration.getName(), this) - .stateTopic(channelConfiguration.rgbStateTopic, channelConfiguration.rgbValueTemplate) - .commandTopic(channelConfiguration.rgbCommandTopic, channelConfiguration.isRetain(), - channelConfiguration.getQos()) - .build(false); + 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); - 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); + 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(); } } diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java index cfc1df26d..2336b80af 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponentTests.java @@ -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 index 000000000..9fda31b5f --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DefaultSchemaLightTests.java @@ -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 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 index 219b6c34b..000000000 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/LightTests.java +++ /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 getConfigTopics() { - return Set.of(CONFIG_TOPIC); - } -}