* 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
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;
return this;
}
+ public Builder withFormat(String format) {
+ this.format = format;
+ return this;
+ }
+
public ComponentChannel build() {
return build(true);
}
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();
@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);
}
case "climate":
return new Climate(componentConfiguration);
case "light":
- return new Light(componentConfiguration);
+ return Light.create(componentConfiguration);
case "lock":
return new Lock(componentConfiguration);
case "sensor":
--- /dev/null
+/**
+ * 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;
+ }
+ }
+}
*/
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;
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
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();
}
}
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;
/**
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;
--- /dev/null
+/**
+ * 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);
+ }
+}
+++ /dev/null
-/**
- * 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);
- }
-}